Entrar Criar conta
Guias

OTP-Token: o cabeçalho de e-mail para códigos de uso único

O cabeçalho que entrega o código de um e-mail de verificação de forma legível por máquina e presa a uma origem, para o cliente de e-mail preenchê-lo na página certa. Formato, envio, leitura e segurança.

#O que é

Muitos e-mails existem só para entregar um código: o de login, o de verificação do cadastro, o de confirmação de uma compra. Hoje a pessoa abre o e-mail, copia o código, volta à página e cola, quase sempre trocando de aplicativo ou de aparelho. Nada liga o código ao site que o pediu, então ele também pode ser digitado numa página falsa (phishing).

O cabeçalho OTP-Token ataca os dois problemas. Ele leva o código num formato que programas leem, junto com a origem (o site) que o pediu. Um cliente de e-mail ou um navegador que entenda o cabeçalho pode oferecer o código só à página daquela origem, preencher o campo sozinho e não entregá-lo a nenhuma outra. É a mesma ideia dos códigos de uso único presos a uma origem que já existem para SMS.

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

Ainda não é um padrão

O OTP-Token é um Internet-Draft individual (draft-goto-otp-token, versão 01, setembro de 2026), não um padrão da IETF. Nomes, parâmetros e sintaxe podem mudar. Se um cliente de e-mail lê o cabeçalho depende do próprio cliente, e a Postal não afirma que nenhum cliente específico o leia hoje.

Adotar agora é seguro porque quem não conhece um cabeçalho o ignora e o código continua no corpo do e-mail. Só não conte com o preenchimento automático.

O que a Postal já faz por você:

#Formato

O valor é um item de Structured Field da RFC 9651: uma String (o código, entre aspas duplas) com o parâmetro obrigatório origin, que também é uma String.

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

O código

  • ASCII imprimível, de espaço a ~. Só a barra invertida e as aspas duplas precisam de escape, com uma barra invertida na frente (\\ e \").
  • Na Postal o código não pode ser vazio: sem código não há o que preencher. O rascunho não diz isso, é uma regra a mais nossa.

A origem

A origem é o site (ou app web) onde a pessoa vai digitar o código, escrita como na RFC 6454 (serialização ASCII): esquema, domínio e, só se não for a padrão, a porta.

  • Minúsculas e ASCII. Domínio com acento vai na forma punycode (xn--…).
  • Sem a porta padrão (443 no https, 80 no http), sem caminho, sem barra no fim e sem ? ou #.
  • Nunca "null" (origem opaca).
  • Potencialmente confiável: https, ou http apenas para localhost e 127.0.0.1, para desenvolver. A Postal aceita só esses dois domínios em http.
  • https://app.exemplo.com e https://exemplo.com são origens diferentes: use a exata da página que pede o código.

Na mensagem

  • Uma vez só. O rascunho aceita um OTP-Token por mensagem. Com dois, a Postal considera que a mensagem não tem código.
  • Só no topo. Vale o cabeçalho da mensagem. Os que estiverem dentro de partes MIME (um anexo ou uma mensagem encaminhada, por exemplo) são ignorados.
  • Parâmetros desconhecidos são ignorados, para o formato poder crescer. Não ponha segredos neles.
  • Linhas longas podem ser dobradas como diz a RFC 5322: uma quebra de linha seguida de espaço. Quem lê desfaz a dobra tirando só a quebra.
  • O nome do cabeçalho não diferencia maiúsculas de minúsculas (otp-token serve).

Exemplos válidos e inválidos

SituaçãoValorPor quê
Válido"123456"; origin="https://example.com"O formato básico.
Válido"123456"; origin="https://app.example.com:8443"Porta que não é a padrão fica na origem.
Válido"123456"; origin="http://localhost:5173"http só vale para localhost e 127.0.0.1, para desenvolver.
Válido"123456"; origin="https://example.com"; token=:YWJjZA==:Parâmetros desconhecidos são ignorados.
Válido"a\"b"; origin="https://example.com"Aspas e barras invertidas no código levam uma barra invertida na frente.
Inválido123456; origin="https://example.com"O código não é uma String (sem aspas, vira um número inteiro).
Inválido"123456"Falta o parâmetro origin.
Inválido"123456"; origin=https://example.comorigin não é uma String (faltam as aspas).
Inválido"123456"; origin="https://example.com/login"A origem não tem caminho.
Inválido"123456"; origin="https://example.com/"Nem a barra no fim.
Inválido"123456"; origin="https://Example.com"A origem é escrita em minúsculas.
Inválido"123456"; origin="https://example.com:443"A porta padrão (443 no https, 80 no http) não aparece.
Inválido"123456"; origin="http://example.com"http fora de localhost e 127.0.0.1 não é confiável.
Inválido"123456"; origin="null"Uma origem opaca (null) não serve.
Inválido"123456"; origin="https://exämple.com"A origem é ASCII: use a forma punycode (xn--…).
Inválido"12é456"; origin="https://example.com"O código só tem ASCII imprimível (de espaço a ~).
Inválido"12"34"; origin="https://example.com"Aspas dentro do código precisam de escape (\").
Inválido""; origin="https://example.com"Código vazio: não há o que preencher (regra a mais da Postal).
Inválido"123456" origin="https://example.com"Falta o ponto e vírgula entre o código e o parâmetro.

#Enviar pela API

Passe o cabeçalho em headers, em POST /emails. O valor é uma string comum: no JSON, as aspas duplas levam barra invertida, e a Postal o entrega exatamente como você mandou.

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);
200Resposta
{
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}

Para conferir o resultado, leia a mensagem original em GET /emails/{id}/raw: o cabeçalho está lá, com o nome na caixa de letras que você usou.

Lote, templates e agendamento

  • Lote (POST /emails/batch): cada e-mail leva o seu headers, com o seu código. Se um valor for inválido, o lote inteiro é recusado com 422, nada é enviado e a mensagem começa com o índice do e-mail, como [1] The `OTP-Token` header must look like….
  • Templates: headers funciona junto com template; o cabeçalho não faz parte do template, é por envio.
  • Agendados (scheduled_at): o cabeçalho é guardado e sai quando chega a hora. Códigos de uso único têm validade curta, então agende só se o horário fizer sentido para a validade.
  • Idempotência: Idempotency-Key funciona como sempre. Use uma chave por pedido de código, não por usuário.

#SDKs e Laravel

Os SDKs enviam headers como estão e trazem um atalho que monta o valor com o escape certo e confere a origem antes de a API recusar. No Node.js é otpTokenHeader(code, origin); no PHP, Postal\OtpToken::header($code, $origin). Se o código ou a origem não servirem, eles lançam PostalError (Node.js) ou PostalException (PHP) com validation_error e statusCode 0, sem chamar a API. A mensagem sugere a origem canônica quando consegue, por exemplo Use "https://app.lojaexemplo.com.br" para um endereço com caminho.

Enviar com o atalho

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));

No Laravel, o Mailable acima funciona com o transporte postal do pacote PHP (que manda os cabeçalhos extras para a API) e com o mailer SMTP: o Symfony Mailer, que o Laravel usa, não codifica o valor. Veja os SDKs em SDKs.

#Enviar por SMTP

Pelo SMTP, a Postal entrega a mensagem como o seu sistema a enviou e não confere o formato do cabeçalho: a validação de formato é da API. Confira você o valor antes.

O cuidado é com a biblioteca de SMTP. Quando uma biblioteca aplica ao valor a codificação de palavras codificadas da RFC 2047, as aspas viram =22 e o cabeçalho passa a ser, por exemplo:

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

Isso não é mais um Structured Field: quem lê não acha o código nem a origem. A codificação existe para texto não ASCII (um assunto com acento), não para um valor que já é ASCII e tem sintaxe própria. Por isso o gateway SMTP da Postal desfaz a codificação: se a mensagem tem um cabeçalho OTP-Token em palavras codificadas (=?UTF-8?B?...?= ou ?Q?), em uma ou várias linhas, a Postal decodifica, desdobra e entrega o valor original ("123456"; origin="https://example.com"), sem codificação, com as linhas longas dobradas só depois do ;. Se o valor decodificado não for um OTP-Token válido, ou se houver dois cabeçalhos, a mensagem sai como chegou, e sem a mensagem de erro da API. O resto da mensagem não muda. Mesmo assim, prefira não codificar. Veja como evitar a codificação nas bibliotecas mais comuns:

Enviar o 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)

O que foi testado

Com o Nodemailer (versões 6 e 10), o valor sai codificado a menos que você use prepared: true. No PHPMailer 7.1, valores com até 47 caracteres saem como estão e os maiores são codificados, por isso a subclasse. O módulo email do Python e o Symfony Mailer (Laravel) não codificam um valor ASCII de tamanho normal. Versões diferentes podem se comportar de outro jeito: confira a mensagem que sai com GET /emails/{id}/raw ou enviando um teste para uma inbox.

Quem precisa de garantia pode enviar pela API ou pelos SDKs, onde a Postal monta a mensagem e não codifica o valor.

#O que a Postal valida

Se a requisição tem um cabeçalho OTP-Token (em qualquer caixa de letras), a API confere o que o rascunho exige e responde 422 validation_error em vez de enviar um cabeçalho que os receptores descartariam. Nada é enviado nem contado na cota. Sem o cabeçalho, nada é conferido nem acrescentado.

  • O valor é uma String entre aspas duplas, só com ASCII imprimível e escapes válidos, e não é vazia.
  • origin existe, é uma String e está na forma canônica da origem.
  • Parâmetros desconhecidos passam.
  • O cabeçalho aparece uma vez só, em qualquer caixa de letras: OTP-Token e otp-token juntos também dão 422.

As mensagens vêm em inglês, como as demais; com Accept-Language: pt-BR vêm em português. Trate o erro pelo name (validation_error), não pelo texto.

422Formato invá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 invá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."
}
422Cabeçalho repetido
{
  "statusCode": 422,
  "name": "validation_error",
  "message": "The `OTP-Token` header can only be set once, whatever the letter case of its name."
}
422Cabeçalho 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."
}

O valor sai como você mandou

A Postal não codifica o valor. As aspas ficam como estão e não viram palavras codificadas da RFC 2047, que deixariam o cabeçalho ilegível como Structured Field. Só as linhas longas são dobradas (uma quebra de linha seguida de espaço, depois do ;), o que o receptor desfaz sem mudar o valor. Um assunto com acento é codificado normalmente e não afeta o OTP-Token.

Limites do campo headers: até 998 caracteres por valor e sem quebras de linha. Veja POST /emails.

#Receber e testar

Nos e-mails que chegam às suas inboxes, a Postal lê o cabeçalho e o entrega pronto no campo otp_token: um objeto {"code", "origin"} ou null. Ele aparece em GET /emails/receiving (lista) e GET /emails/receiving/{id}, nas mensagens recebidas de GET /inboxes/{id}/threads/{thread_id} e na ferramenta get_inbox_thread do MCP. É preciso uma chave de acesso total. O valor do cabeçalho, desdobrado e, se veio em palavras codificadas da RFC 2047, decodificado, fica em 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);
200Resposta
{
  "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": []
}

Leitura estrita

A leitura é do tipo tudo ou nada: otp_token só vem preenchido quando a mensagem tem exatamente um cabeçalho OTP-Token na seção de cabeçalhos do topo, desdobrado como manda a RFC 5322, com uma String não vazia e um origin String na forma canônica. Qualquer desvio dá null, nunca um resultado parcial. Cabeçalhos dentro de partes MIME ou de mensagens anexadas são ignorados, parâmetros desconhecidos também. E-mails recebidos antes de o recurso existir não têm o valor guardado e também dão null.

Por que o código não vai no webhook

O código é um segredo de curta duração. O payload de email.received segue como sempre (remetente, assunto, anexos), sem otp_token: um webhook passa por filas, novas tentativas, logs e ferramentas de monitoramento, lugares onde um código não deveria ficar. Use o webhook para saber que o e-mail chegou e busque o código com GET /emails/receiving/{id}, que exige a sua chave.

No painel

Ao abrir a mensagem em Inboxes, uma linha mostra Código 123456 · origem exemplo.com, com um botão para copiar o código. Ela só aparece quando o cabeçalho é válido.

Testar o login de ponta a ponta

O uso mais direto é nos testes automatizados do seu produto: em vez de abrir uma caixa de e-mail de verdade, o teste pede o código, busca o e-mail na inbox e digita o código. Crie uma inbox de teste (com catch_all, cada execução usa um endereço novo) e deixe o e-mail de login do ambiente de testes chegar a ela.

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/);
});

O teste confere também a origin, o que pega o dia em que alguém muda o domínio do app e esquece o cabeçalho. Com cURL, é a mesma 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"

Cuide da inbox de teste

Quem tem uma chave de acesso total lê os códigos que chegam. Use uma inbox só para testes, com e-mails de teste, e nunca receba nela os códigos de pessoas reais.

#Os e-mails da própria Postal

Os e-mails de código que a Postal manda para você (entrar, criar a conta e confirmar que é você) já levam o OTP-Token, com a origem do painel, porque é lá que o código é digitado:

Cabeçalho
OTP-Token: "123456"; origin="https://app.postal.so"

O código continua visível no corpo do e-mail. Se um dia o endereço do painel não servir como origem (um painel em http fora de localhost, por exemplo), o cabeçalho é omitido e o e-mail sai normalmente.

#Boas práticas de segurança

  • Prenda a origem à página que pede o código. Use a origem exata do site ou app web onde a pessoa digita o código, nunca a do seu servidor de e-mail ou da API.
  • Validade curta e uso único. Alguns minutos bastam. Invalide o código depois de usado e quando um novo for pedido.
  • Limite as tentativas. Bloqueie ou atrase depois de poucos erros e limite quantos códigos um mesmo endereço ou IP pode pedir. Um código de 6 dígitos sem limite de tentativas se adivinha.
  • Mantenha o código visível no corpo. O cabeçalho não aparece para a pessoa. Clientes que não o leem, e quem abre o e-mail em outro aparelho, precisam do texto. Diga também para não compartilhar o código.
  • Só o código. Não ponha tokens de sessão, links de recuperação ou outros segredos no cabeçalho, nem em parâmetros extras.
  • Autentique o seu domínio. O cabeçalho só tem valor se quem lê confia em quem enviou. Mantenha DKIM, SPF e DMARC alinhados com o domínio do remetente (veja Domínios e DNS); sem isso, qualquer um pode forjar um e-mail com o seu domínio e o seu OTP-Token.
  • Não registre o código. Fique de olho em logs, ferramentas de erro e webhooks para ele não parar lá.
  • E-mail é plano B, não substituto. Código por e-mail é um bom caminho de recuperação, mas passkeys e outros fatores resistentes a phishing devem vir primeiro quando você puder oferecê-los. O OTP-Token reduz o phishing do código, não o elimina.

#Perguntas frequentes

O Gmail ou o Apple Mail já leem o cabeçalho?

Não sabemos, e não afirmamos suporte de nenhum cliente específico. O OTP-Token é um rascunho recente, e cada cliente decide se o lê e quando. Consulte a documentação do cliente que importa para você.

Enviar o cabeçalho não atrapalha quem não o lê, porque cabeçalhos que o cliente não conhece são ignorados, e o código continua no corpo.

Preciso mudar o corpo do e-mail?

Não. Mantenha o código visível no texto e no HTML. O cabeçalho é um complemento para programas, e a pessoa continua vendo o código como antes.

E se o cabeçalho for inválido?

Pela API, a Postal responde 422 com o motivo e nada é enviado (veja O que a Postal valida). Pelo SMTP, a mensagem sai como veio e os receptores simplesmente ignoram um cabeçalho inválido. Ao receber, um cabeçalho inválido vira otp_token: null.

Funciona com SMTP?

Sim. A Postal não mexe na mensagem. Só tome cuidado para a sua biblioteca de SMTP não codificar o valor (veja Enviar por SMTP).

Funciona em templates e e-mails agendados?

Sim. O campo headers vale em POST /emails, no lote, com template e com scheduled_at.

Posso usar em localhost?

Pode: http://localhost:5173 e http://127.0.0.1:3000 são aceitos para desenvolvimento. Qualquer outro domínio em http é recusado.

O que é origin?

É a identidade de um site na web: esquema, domínio e porta, como em https://app.exemplo.com (a porta só aparece se não for a padrão). Páginas com origens diferentes não se misturam: https://exemplo.com, https://app.exemplo.com e http://exemplo.com são três origens. Prender o código à origem é o que impede que uma página falsa o receba.

Qual a diferença para o List-Unsubscribe?

Os dois são cabeçalhos que o cliente de e-mail lê para oferecer uma ação sem abrir o corpo. O List-Unsubscribe (RFC 2369 e RFC 8058) é um padrão antigo, amplamente usado, para sair de uma lista; a Postal o coloca em todo e-mail de marketing. O OTP-Token é um rascunho para preencher um código de uso único. Servem a e-mails diferentes: o primeiro, a campanhas e automações; o segundo, a e-mails de código.

#Referências

  • draft-goto-otp-token: o Internet-Draft do cabeçalho OTP-Token (versão 01).
  • RFC 9651: Structured Field Values for HTTP, a sintaxe do valor.
  • RFC 6454: The Web Origin Concept, a serialização da origem.
  • RFC 5322: Internet Message Format, os cabeçalhos e as linhas dobradas.
  • RFC 2047: MIME, parte três, as palavras codificadas que não devem ser usadas neste valor.

Veja também Enviar e-mail, Recebidos e inboxes, SMTP e SDKs.