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.
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ê:
- entrega o cabeçalho sem alterá-lo, pela API e pelo SMTP, e recusa na hora um valor que os receptores descartariam;
- lê o cabeçalho nos e-mails que você recebe e entrega o código no campo
otp_token, útil para testar o login de ponta a ponta; - já o usa nos próprios e-mails de login;
- tem atalhos nos SDKs para montar o valor sem errar o escape.
#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.
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 nohttp), sem caminho, sem barra no fim e sem?ou#. - Nunca
"null"(origem opaca). - Potencialmente confiável:
https, ouhttpapenas paralocalhoste127.0.0.1, para desenvolver. A Postal aceita só esses dois domínios emhttp. https://app.exemplo.comehttps://exemplo.comsão origens diferentes: use a exata da página que pede o código.
Na mensagem
- Uma vez só. O rascunho aceita um
OTP-Tokenpor 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-tokenserve).
Exemplos válidos e inválidos
| Situação | Valor | Por 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álido | 123456; 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.com | origin 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.
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);
{
"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 seuheaders, com o seu código. Se um valor for inválido, o lote inteiro é recusado com422, nada é enviado e a mensagem começa com o índice do e-mail, como[1] The `OTP-Token` header must look like…. - Templates:
headersfunciona junto comtemplate; 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-Keyfunciona 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.
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:
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:
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.
originexiste, é 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-Tokeneotp-tokenjuntos 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.
{
"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."
}
{
"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."
}
{
"statusCode": 422,
"name": "validation_error",
"message": "The `OTP-Token` header can only be set once, whatever the letter case of its name."
}
{
"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"].
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);
{
"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
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 (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:
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:
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.