Iniciar sesión Crear cuenta
Guías

OTP-Token: el encabezado de correo para códigos de un solo uso

El encabezado que entrega el código de un correo de verificación de forma legible por máquina y ligada a un origen, para que el cliente de correo lo rellene en la página correcta. Formato, envío, lectura y seguridad.

#Qué es

Muchos correos existen solo para entregar un código: el de inicio de sesión, el de verificación del registro, el de confirmación de una compra. Hoy la persona abre el correo, copia el código, vuelve a la página y lo pega, casi siempre cambiando de aplicación o de dispositivo. Nada vincula el código con el sitio que lo pidió, así que también puede escribirse en una página falsa (phishing).

El encabezado OTP-Token ataca ambos problemas. Lleva el código en un formato que los programas leen, junto con el origen (el sitio) que lo pidió. Un cliente de correo o un navegador que entienda el encabezado puede ofrecer el código solo a la página de ese origen, rellenar el campo automáticamente y no entregarlo a ninguna otra. Es la misma idea de los códigos de un solo uso ligados a un origen que ya existen para SMS.

Ejemplo
OTP-Token: "123456"; origin="https://example.com"

Todavía no es un estándar

OTP-Token es un Internet-Draft individual (draft-goto-otp-token, versión 01, septiembre de 2026), no un estándar de la IETF. Los nombres, los parámetros y la sintaxis pueden cambiar. Que un cliente de correo lea el encabezado depende del propio cliente, y Postal no afirma que ningún cliente concreto lo lea hoy.

Adoptarlo ahora es seguro porque quien no conoce un encabezado lo ignora y el código sigue en el cuerpo del correo. Solo que no cuentes con el autocompletado.

Lo que Postal ya hace por ti:

#Formato

El valor es un elemento de Structured Field de la RFC 9651: una String (el código, entre comillas dobles) con el parámetro obligatorio origin, que también es una String.

Sintaxis
OTP-Token: "<código>"; origin="<origem>"

El código

  • ASCII imprimible, de espacio a ~. Solo la barra invertida y las comillas dobles necesitan escape, con una barra invertida delante (\\ y \").
  • En Postal el código no puede estar vacío: sin código no hay nada que rellenar. El borrador no dice esto, es una regla adicional nuestra.

El origen

El origen es el sitio (o aplicación web) donde la persona va a escribir el código, escrito como en la RFC 6454 (serialización ASCII): esquema, dominio y, solo si no es el predeterminado, el puerto.

  • Minúsculas y ASCII. Un dominio con tilde va en forma punycode (xn--…).
  • Sin el puerto predeterminado (443 en https, 80 en http), sin ruta, sin barra al final y sin ? ni #.
  • Nunca "null" (origen opaco).
  • Potencialmente fiable: https, o http solo para localhost y 127.0.0.1, para desarrollar. Postal acepta solo esos dos dominios en http.
  • https://app.exemplo.com y https://exemplo.com son orígenes distintos: usa el exacto de la página que pide el código.

En el mensaje

  • Una sola vez. El borrador acepta un OTP-Token por mensaje. Con dos, Postal considera que el mensaje no tiene código.
  • Solo arriba. Vale el encabezado del mensaje. Los que estén dentro de partes MIME (un adjunto o un mensaje reenviado, por ejemplo) se ignoran.
  • Los parámetros desconocidos se ignoran, para que el formato pueda crecer. No pongas secretos en ellos.
  • Las líneas largas pueden plegarse como indica la RFC 5322: un salto de línea seguido de un espacio. Quien lee deshace el pliegue quitando solo el salto.
  • El nombre del encabezado no distingue mayúsculas de minúsculas (otp-token sirve).

Ejemplos válidos e inválidos

SituaciónValorPor qué
Válido"123456"; origin="https://example.com"El formato básico.
Válido"123456"; origin="https://app.example.com:8443"Un puerto que no es el predeterminado va en el origen.
Válido"123456"; origin="http://localhost:5173"http solo vale para localhost y 127.0.0.1, para desarrollar.
Válido"123456"; origin="https://example.com"; token=:YWJjZA==:Los parámetros desconocidos se ignoran.
Válido"a\"b"; origin="https://example.com"Las comillas y las barras invertidas en el código llevan una barra invertida delante.
Inválido123456; origin="https://example.com"El código no es una String (sin comillas, se vuelve un número entero).
Inválido"123456"Falta el parámetro origin.
Inválido"123456"; origin=https://example.comorigin no es una String (faltan las comillas).
Inválido"123456"; origin="https://example.com/login"El origen no tiene ruta.
Inválido"123456"; origin="https://example.com/"Ni siquiera la barra final.
Inválido"123456"; origin="https://Example.com"El origen se escribe en minúsculas.
Inválido"123456"; origin="https://example.com:443"El puerto predeterminado (443 en https, 80 en http) no aparece.
Inválido"123456"; origin="http://example.com"http fuera de localhost y 127.0.0.1 no es fiable.
Inválido"123456"; origin="null"Un origen opaco (null) no sirve.
Inválido"123456"; origin="https://exämple.com"El origen es ASCII: usa la forma punycode (xn--…).
Inválido"12é456"; origin="https://example.com"El código solo tiene ASCII imprimible (de espacio a ~).
Inválido"12"34"; origin="https://example.com"Las comillas dentro del código necesitan escape (\").
Inválido""; origin="https://example.com"Código vacío: no hay nada que rellenar (regla adicional de Postal).
Inválido"123456" origin="https://example.com"Falta el punto y coma entre el código y el parámetro.

#Enviar por la API

Pasa el encabezado en headers, en POST /emails. El valor es una cadena común: en JSON, las comillas dobles llevan barra invertida, y Postal lo entrega exactamente como lo enviaste.

POST /emails

cURL

curl -X POST https://api.postal.so/emails \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Loja Exemplo <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Seu código de acesso",
    "html": "<p>Seu código de acesso é <b>482913</b>. Ele vale por 10 minutos.</p>",
    "headers": {
      "OTP-Token": "\"482913\"; origin=\"https://app.lojaexemplo.com.br\""
    }
  }'

Node.js

const response = await fetch('https://api.postal.so/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    from: 'Loja Exemplo <[email protected]>',
    to: ['[email protected]'],
    subject: 'Seu código de acesso',
    html: '<p>Seu código de acesso é <b>482913</b>. Ele vale por 10 minutos.</p>',
    headers: {
      'OTP-Token': '"482913"; origin="https://app.lojaexemplo.com.br"',
    },
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/emails",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "from": "Loja Exemplo <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Seu código de acesso",
        "html": "<p>Seu código de acesso é <b>482913</b>. Ele vale por 10 minutos.</p>",
        "headers": {
            "OTP-Token": "\"482913\"; origin=\"https://app.lojaexemplo.com.br\"",
        },
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('emails', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'from' => 'Loja Exemplo <[email protected]>',
        'to' => ['[email protected]'],
        'subject' => 'Seu código de acesso',
        'html' => '<p>Seu código de acesso é <b>482913</b>. Ele vale por 10 minutos.</p>',
        'headers' => [
            'OTP-Token' => '"482913"; origin="https://app.lojaexemplo.com.br"',
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}

Para comprobar el resultado, lee el mensaje original en GET /emails/{id}/raw: el encabezado está ahí, con el nombre en las mayúsculas y minúsculas que usaste.

Lote, plantillas y programación

  • Lote (POST /emails/batch): cada correo lleva su propio headers, con su código. Si un valor no es válido, se rechaza el lote entero con 422, no se envía nada y el mensaje empieza con el índice del correo, como [1] The `OTP-Token` header must look like….
  • Plantillas: headers funciona junto con template; el encabezado no forma parte de la plantilla, es por envío.
  • Programados (scheduled_at): el encabezado se guarda y sale cuando llega la hora. Los códigos de un solo uso tienen una validez corta, así que programa solo si la hora tiene sentido para la validez.
  • Idempotencia: Idempotency-Key funciona como siempre. Usa una clave por solicitud de código, no por usuario.

#SDKs y Laravel

Los SDKs envían headers tal cual y traen un atajo que arma el valor con el escape correcto y comprueba el origen antes de que la API lo rechace. En Node.js es otpTokenHeader(code, origin); en PHP, Postal\OtpToken::header($code, $origin). Si el código o el origen no sirven, lanzan PostalError (Node.js) o PostalException (PHP) con validation_error y statusCode 0, sin llamar a la API. El mensaje sugiere el origen canónico cuando puede, por ejemplo Use "https://app.lojaexemplo.com.br" para una dirección con ruta.

Enviar con el atajo

Node.js

import { Postal, otpTokenHeader } from 'postal-so';

const postal = new Postal(); // lê POSTAL_API_KEY

await postal.emails.send({
  from: 'Loja Exemplo <[email protected]>',
  to: '[email protected]',
  subject: 'Seu código de acesso',
  html: '<p>Seu código de acesso é <b>482913</b>. Ele vale por 10 minutos.</p>',
  headers: { 'OTP-Token': otpTokenHeader('482913', 'https://app.lojaexemplo.com.br') },
});
// otpTokenHeader devolve: "482913"; origin="https://app.lojaexemplo.com.br"

PHP

use Postal\Client;
use Postal\OtpToken;

$postal = new Client(); // lê POSTAL_API_KEY

$postal->emails->send([
    'from' => 'Loja Exemplo <[email protected]>',
    'to' => '[email protected]',
    'subject' => 'Seu código de acesso',
    'html' => '<p>Seu código de acesso é <b>482913</b>. Ele vale por 10 minutos.</p>',
    'headers' => ['OTP-Token' => OtpToken::header('482913', 'https://app.lojaexemplo.com.br')],
]);
// OtpToken::header devolve: "482913"; origin="https://app.lojaexemplo.com.br"

Laravel

// app/Mail/LoginCode.php
namespace App\Mail;

use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Postal\OtpToken;

class LoginCode extends Mailable
{
    public function __construct(public string $code)
    {
    }

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'Seu código de acesso');
    }

    public function content(): Content
    {
        return new Content(view: 'emails.login-code'); // o código também aparece no corpo
    }

    public function headers(): Headers
    {
        return new Headers(text: [
            'OTP-Token' => OtpToken::header($this->code, 'https://app.lojaexemplo.com.br'),
        ]);
    }
}

// Em qualquer lugar da aplicação
Mail::to($user)->send(new LoginCode($code));

En Laravel, el Mailable de arriba funciona con el transporte postal del paquete PHP (que envía los encabezados adicionales a la API) y con el mailer SMTP: Symfony Mailer, que usa Laravel, no codifica el valor. Consulta los SDKs en SDKs.

#Enviar por SMTP

Por SMTP, Postal entrega el mensaje tal como lo envió tu sistema y no comprueba el formato del encabezado: la validación de formato es de la API. Comprueba tú el valor antes.

El cuidado está en la biblioteca de SMTP. Cuando una biblioteca aplica al valor la codificación de palabras codificadas de la RFC 2047, las comillas se convierten en =22 y el encabezado pasa a ser, por ejemplo:

Valor codificado (evítalo)
OTP-Token: =?UTF-8?Q?=22123456=22=3B_origin=3D=22https=3A//ex?=
 =?UTF-8?Q?ample=2Ecom=22?=

Eso ya no es un Structured Field: quien lee no encuentra ni el código ni el origen. La codificación existe para texto no ASCII (un asunto con tilde), no para un valor que ya es ASCII y tiene su propia sintaxis. Por eso la pasarela SMTP de Postal deshace la codificación: si el mensaje tiene un encabezado OTP-Token en palabras codificadas (=?UTF-8?B?...?= o ?Q?), en una o varias líneas, Postal lo decodifica, lo despliega y entrega el valor original ("123456"; origin="https://example.com"), sin codificación, con las líneas largas plegadas solo después del ;. Si el valor decodificado no es un OTP-Token válido, o si hay dos encabezados, el mensaje sale tal como llegó, y sin el mensaje de error de la API. El resto del mensaje no cambia. Aun así, prefiere no codificar. Mira cómo evitar la codificación en las bibliotecas más comunes:

Enviar el OTP-Token por SMTP

Node.js

import nodemailer from 'nodemailer';
import { otpTokenHeader } from 'postal-so';

const transporter = nodemailer.createTransport({
  host: 'smtp.postal.so',
  port: 465,
  secure: true,
  auth: { user: 'postal', pass: process.env.POSTAL_API_KEY },
});

await transporter.sendMail({
  from: 'Loja Exemplo <[email protected]>',
  to: '[email protected]',
  subject: 'Seu código de acesso',
  html: '<p>Seu código de acesso é <b>482913</b>. Ele vale por 10 minutos.</p>',
  headers: {
    // "prepared: true" faz o Nodemailer enviar o valor como está, sem codificá-lo (RFC 2047)
    'OTP-Token': { prepared: true, value: otpTokenHeader('482913', 'https://app.lojaexemplo.com.br') },
  },
});

PHP

use PHPMailer\PHPMailer\PHPMailer;
use Postal\OtpToken;

// O PHPMailer codifica (RFC 2047) valores de cabeçalho com mais de 47 caracteres.
// Esta subclasse deixa o OTP-Token como está.
class OtpMailer extends PHPMailer
{
    public function encodeHeader($str, $position = 'text')
    {
        if (str_starts_with($str, '"') && str_contains($str, '; origin="')) {
            return $str;
        }

        return parent::encodeHeader($str, $position);
    }
}

$mail = new OtpMailer(true);
$mail->isSMTP();
$mail->Host = 'smtp.postal.so';
$mail->Port = 465;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->SMTPAuth = true;
$mail->Username = 'postal';
$mail->Password = getenv('POSTAL_API_KEY');
$mail->CharSet = 'UTF-8';

$mail->setFrom('[email protected]', 'Loja Exemplo');
$mail->addAddress('[email protected]');
$mail->Subject = 'Seu código de acesso';
$mail->isHTML(true);
$mail->Body = '<p>Seu código de acesso é <b>482913</b>. Ele vale por 10 minutos.</p>';
$mail->addCustomHeader('OTP-Token', OtpToken::header('482913', 'https://app.lojaexemplo.com.br'));
$mail->send();

Python

import os
import smtplib
from email.message import EmailMessage

message = EmailMessage()
message["From"] = "Loja Exemplo <[email protected]>"
message["To"] = "[email protected]"
message["Subject"] = "Seu código de acesso"
message["OTP-Token"] = '"482913"; origin="https://app.lojaexemplo.com.br"'
message.set_content("Seu código de acesso é 482913. Ele vale por 10 minutos.")

with smtplib.SMTP_SSL("smtp.postal.so", 465) as server:
    server.login("postal", os.environ["POSTAL_API_KEY"])
    server.send_message(message)

Qué se probó

Con Nodemailer (versiones 6 y 10), el valor sale codificado a menos que uses prepared: true. En PHPMailer 7.1, los valores de hasta 47 caracteres salen tal cual y los más largos se codifican, por eso la subclase. El módulo email de Python y Symfony Mailer (Laravel) no codifican un valor ASCII de tamaño normal. Otras versiones pueden comportarse de otra manera: comprueba el mensaje que sale con GET /emails/{id}/raw o enviando una prueba a un buzón de entrada.

Quien necesite garantía puede enviar por la API o por los SDKs, donde Postal arma el mensaje y no codifica el valor.

#Qué valida Postal

Si la solicitud tiene un encabezado OTP-Token (con cualquier combinación de mayúsculas y minúsculas), la API comprueba lo que exige el borrador y responde 422 validation_error en lugar de enviar un encabezado que los receptores descartarían. No se envía nada ni se cuenta en la cuota. Sin el encabezado, no se comprueba ni se añade nada.

  • El valor es una String entre comillas dobles, solo con ASCII imprimible y escapes válidos, y no está vacía.
  • origin existe, es una String y está en la forma canónica del origen.
  • Los parámetros desconocidos pasan.
  • El encabezado aparece una sola vez, con cualquier combinación de mayúsculas y minúsculas: OTP-Token y otp-token juntos también dan 422.

Los mensajes vienen en inglés, como los demás; con Accept-Language: pt-BR vienen en portugués. Trata el error por el name (validation_error), no por el texto.

422Formato no válido
{
  "statusCode": 422,
  "name": "validation_error",
  "message": "The `OTP-Token` header must look like `\"123456\"; origin=\"https://example.com\"`: the code in double quotes and an `origin` parameter with the https origin (scheme and host, lowercase, no path) that asks for the code."
}
422Formato no válido (Accept-Language: pt-BR)
{
  "statusCode": 422,
  "name": "validation_error",
  "message": "O cabeçalho `OTP-Token` precisa ter o formato `\"123456\"; origin=\"https://example.com\"`: o código entre aspas duplas e um parâmetro `origin` com a origem https (esquema e domínio, em minúsculas, sem caminho) que pede o código."
}
422Encabezado repetido
{
  "statusCode": 422,
  "name": "validation_error",
  "message": "The `OTP-Token` header can only be set once, whatever the letter case of its name."
}
422Encabezado repetido (Accept-Language: pt-BR)
{
  "statusCode": 422,
  "name": "validation_error",
  "message": "O cabeçalho `OTP-Token` só pode ser definido uma vez, qualquer que seja a caixa das letras do nome."
}

El valor sale como lo enviaste

Postal no codifica el valor. Las comillas quedan como están y no se convierten en palabras codificadas de la RFC 2047, que dejarían el encabezado ilegible como Structured Field. Solo se pliegan las líneas largas (un salto de línea seguido de un espacio, después del ;), lo que el receptor deshace sin cambiar el valor. Un asunto con tilde se codifica con normalidad y no afecta al OTP-Token.

Límites del campo headers: hasta 998 caracteres por valor y sin saltos de línea. Consulta POST /emails.

#Recibir y probar

En los correos que llegan a tus buzones de entrada, Postal lee el encabezado y lo entrega listo en el campo otp_token: un objeto {"code", "origin"} o null. Aparece en GET /emails/receiving (lista) y GET /emails/receiving/{id}, en los mensajes recibidos de GET /inboxes/{id}/threads/{thread_id} y en la herramienta get_inbox_thread de MCP. Hace falta una clave de acceso total. El valor del encabezado, desplegado y, si llegó en palabras codificadas de la RFC 2047, decodificado, queda en headers["otp-token"].

GET /emails/receiving/{id}

cURL

curl https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "object": "received_email",
  "id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
  "inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
  "created_at": "2026-10-24T12:41:09.000000Z",
  "from": "Loja Exemplo <[email protected]>",
  "to": ["[email protected]"],
  "cc": [],
  "subject": "Seu código de acesso",
  "message_id": "<[email protected]>",
  "read": false,
  "size": 18342,
  "has_attachments": false,
  "otp_token": {
    "code": "482913",
    "origin": "https://app.lojaexemplo.com.br"
  },
  "reply_to": [],
  "html": "<p>Seu código de acesso é <b>482913</b>. Ele vale por 10 minutos.</p>",
  "text": null,
  "headers": {
    "otp-token": ["\"482913\"; origin=\"https://app.lojaexemplo.com.br\""],
    "date": "Tue, 27 Oct 2026 10:12:07 -0300"
  },
  "attachments": []
}

Lectura estricta

La lectura es de todo o nada: otp_token solo viene relleno cuando el mensaje tiene exactamente un encabezado OTP-Token en la sección de encabezados superior, desplegado como manda la RFC 5322, con una String no vacía y un origin String en la forma canónica. Cualquier desviación da null, nunca un resultado parcial. Los encabezados dentro de partes MIME o de mensajes adjuntos se ignoran, y los parámetros desconocidos también. Los correos recibidos antes de que existiera el recurso no tienen el valor guardado y también dan null.

Por qué el código no va en el webhook

El código es un secreto de corta duración. El payload de email.received sigue como siempre (remitente, asunto, adjuntos), sin otp_token: un webhook pasa por colas, reintentos, registros y herramientas de monitoreo, lugares donde un código no debería quedar. Usa el webhook para saber que el correo llegó y obtén el código con GET /emails/receiving/{id}, que exige tu clave.

En el panel

Al abrir el mensaje en Buzones de entrada, una línea muestra Código 123456 · origen exemplo.com, con un botón para copiar el código. Solo aparece cuando el encabezado es válido.

Probar el inicio de sesión de punta a punta

El uso más directo está en las pruebas automatizadas de tu producto: en lugar de abrir un buzón de correo real, la prueba pide el código, busca el correo en el buzón de entrada y escribe el código. Crea un buzón de entrada de prueba (con catch_all, cada ejecución usa una dirección nueva) y deja que el correo de inicio de sesión del entorno de pruebas llegue a él.

tests/helpers/wait-for-otp.js
// tests/helpers/wait-for-otp.js
const API = 'https://api.postal.so';
const headers = { Authorization: `Bearer ${process.env.POSTAL_API_KEY}` };

/**
 * Espera o e-mail de login chegar na inbox de teste e devolve { code, origin }.
 * Marca o e-mail como lido para que a próxima execução não reutilize o código.
 */
export async function waitForOtp({ inboxId, recipient, timeoutMs = 30_000 }) {
  const query = new URLSearchParams({ inbox_id: inboxId, unread: 'true', search: recipient, limit: '5' });
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const response = await fetch(`${API}/emails/receiving?${query}`, { headers });
    const { data } = await response.json();
    const email = data.find((item) => item.otp_token !== null);

    if (email) {
      await fetch(`${API}/emails/receiving/${email.id}`, {
        method: 'PATCH',
        headers: { ...headers, 'Content-Type': 'application/json' },
        body: JSON.stringify({ read: true }),
      });

      return email.otp_token;
    }

    await new Promise((resolve) => setTimeout(resolve, 1000));
  }

  throw new Error(`Nenhum e-mail com OTP-Token chegou para ${recipient} em ${timeoutMs} ms.`);
}
tests/login.spec.js
// tests/login.spec.js (Playwright)
import { test, expect } from '@playwright/test';
import { waitForOtp } from './helpers/wait-for-otp.js';

test('entra com o código recebido por e-mail', async ({ page }) => {
  const recipient = `teste-${Date.now()}@suporte.lojaexemplo.com.br`; // inbox de teste com catch-all

  await page.goto('https://app.lojaexemplo.com.br/entrar');
  await page.getByLabel('E-mail').fill(recipient);
  await page.getByRole('button', { name: 'Receber código' }).click();

  const { code, origin } = await waitForOtp({ inboxId: process.env.TEST_INBOX_ID, recipient });

  expect(origin).toBe('https://app.lojaexemplo.com.br'); // o cabeçalho está preso à página certa
  await page.getByLabel('Código').fill(code);
  await page.getByRole('button', { name: 'Entrar' }).click();
  await expect(page).toHaveURL(/\/painel/);
});

La prueba comprueba también el origin, lo que detecta el día en que alguien cambia el dominio de la app y olvida el encabezado. Con cURL, es la misma consulta:

Terminal
curl 'https://api.postal.so/emails/receiving?inbox_id=0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f&unread=true&limit=5' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Cuida el buzón de entrada de prueba

Quien tiene una clave de acceso total lee los códigos que llegan. Usa un buzón de entrada solo para pruebas, con correos de prueba, y nunca recibas en él los códigos de personas reales.

#Los correos de la propia Postal

Los correos con código que Postal te envía (iniciar sesión, crear la cuenta y confirmar que eres tú) ya llevan el OTP-Token, con el origen del panel, porque es allí donde se escribe el código:

Encabezado
OTP-Token: "123456"; origin="https://app.postal.so"

El código sigue visible en el cuerpo del correo. Si un día la dirección del panel no sirve como origen (un panel en http fuera de localhost, por ejemplo), el encabezado se omite y el correo sale con normalidad.

#Buenas prácticas de seguridad

  • Ata el origen a la página que pide el código. Usa el origen exacto del sitio o app web donde la persona escribe el código, nunca el de tu servidor de correo o de la API.
  • Validez corta y uso único. Bastan unos minutos. Invalida el código después de usarlo y cuando se pida uno nuevo.
  • Limita los intentos. Bloquea o retrasa tras pocos errores y limita cuántos códigos puede pedir una misma dirección o IP. Un código de 6 dígitos sin límite de intentos se puede adivinar.
  • Mantén el código visible en el cuerpo. El encabezado no se muestra a la persona. Los clientes que no lo leen, y quien abre el correo en otro dispositivo, necesitan el texto. Indica también que no comparta el código.
  • Solo el código. No pongas tokens de sesión, enlaces de recuperación ni otros secretos en el encabezado, ni en parámetros adicionales.
  • Autentica tu dominio. El encabezado solo tiene valor si quien lo lee confía en quien lo envió. Mantén DKIM, SPF y DMARC alineados con el dominio del remitente (consulta Dominios y DNS); sin eso, cualquiera puede falsificar un correo con tu dominio y tu OTP-Token.
  • No registres el código. Vigila los registros, las herramientas de errores y los webhooks para que no termine allí.
  • El correo electrónico es el plan B, no un sustituto. El código por correo es un buen camino de recuperación, pero las passkeys y otros factores resistentes al phishing deben ir primero cuando puedas ofrecerlos. El OTP-Token reduce el phishing del código, no lo elimina.

#Preguntas frecuentes

¿Gmail o Apple Mail ya leen el encabezado?

No lo sabemos, y no afirmamos que ningún cliente específico lo admita. El OTP-Token es un borrador reciente, y cada cliente decide si lo lee y cuándo. Consulta la documentación del cliente que te importa.

Enviar el encabezado no molesta a quien no lo lee, porque los encabezados que el cliente no conoce se ignoran, y el código sigue en el cuerpo.

¿Tengo que cambiar el cuerpo del correo?

No. Mantén el código visible en el texto y en el HTML. El encabezado es un complemento para programas, y la persona sigue viendo el código como antes.

¿Y si el encabezado no es válido?

Por la API, Postal responde 422 con el motivo y no se envía nada (consulta Qué valida Postal). Por SMTP, el mensaje sale tal como llegó y los receptores simplemente ignoran un encabezado no válido. Al recibir, un encabezado no válido se convierte en otp_token: null.

¿Funciona con SMTP?

Sí. Postal no modifica el mensaje. Solo ten cuidado de que tu biblioteca de SMTP no codifique el valor (consulta Enviar por SMTP).

¿Funciona con plantillas y correos programados?

Sí. El campo headers vale en POST /emails, en el lote, con template y con scheduled_at.

¿Puedo usarlo en localhost?

Sí: http://localhost:5173 y http://127.0.0.1:3000 se aceptan para desarrollo. Cualquier otro dominio en http se rechaza.

¿Qué es origin?

Es la identidad de un sitio en la web: esquema, dominio y puerto, como en https://app.exemplo.com (el puerto solo aparece si no es el predeterminado). Las páginas con orígenes distintos no se mezclan: https://exemplo.com, https://app.exemplo.com y http://exemplo.com son tres orígenes. Atar el código al origen es lo que impide que una página falsa lo reciba.

¿Cuál es la diferencia con List-Unsubscribe?

Los dos son encabezados que el cliente de correo lee para ofrecer una acción sin abrir el cuerpo. List-Unsubscribe (RFC 2369 y RFC 8058) es un estándar antiguo, ampliamente usado, para darse de baja de una lista; Postal lo incluye en todo correo de marketing. OTP-Token es un borrador para rellenar un código de un solo uso. Sirven a correos distintos: el primero, a campañas y automatizaciones; el segundo, a correos con código.

#Referencias

  • draft-goto-otp-token: el Internet-Draft del encabezado OTP-Token (versión 01).
  • RFC 9651: Structured Field Values for HTTP, la sintaxis del valor.
  • RFC 6454: The Web Origin Concept, la serialización del origen.
  • RFC 5322: Internet Message Format, los encabezados y las líneas plegadas.
  • RFC 2047: MIME, parte tres, las palabras codificadas que no deben usarse en este valor.

Consulta también Enviar correo electrónico, Recibidos y buzones de entrada, SMTP y SDKs.