AWS SES: recebendo e-mails e mandando pra um webhook
Olá meus Unicórnios! 🦄✨
Todo mundo conhece o Amazon SES para enviar e-mail. Mas ele também sabe receber — e essa parte quase ninguém conta. 😅 Você aponta o MX do seu domínio para a AWS, cria uma regra, e cada mensagem que chega vira uma requisição HTTP no endereço que você escolher.
Foi exatamente o que eu precisei: um sistema que reage a e-mail que chega, sem ficar abrindo caixa postal com IMAP de minuto em minuto. E o caminho é este aqui: SES recebe → SNS publica → seu webhook recebe.
Montei tudo num domínio de teste, teste1.hogsmeade.com.br,
e no meio do caminho tomei dois tombos que valem o artigo inteiro. Vem
comigo. 🚀
🗺️ O caminho que o e-mail faz
Antes de clicar em qualquer coisa, vale entender as três peças. Elas são separadas de propósito, e é por isso que a configuração parece maior do que é:
alguem manda e-mail para [email protected]
|
v
DNS (registro MX) -> aponta o dominio para os servidores do SES
|
v
SES -> regra de recebimento: "o que eu faco com esta mensagem?"
|
v
SNS -> topico que publica a mensagem para quem estiver inscrito
|
v
seu webhook -> POST com o e-mail inteiro em JSON
Repare que o SES não fala com o seu servidor diretamente. Quem entrega é o SNS, e é ele que vai bater na sua porta. Isso importa mais adiante, quando o SNS pedir para você provar que a porta é sua mesmo. 😏
📮 Verificando o domínio no SES
O primeiro passo é provar para a AWS que o domínio é seu. No painel do SES, em Identidades, você cria uma identidade do tipo Domínio:
Ao salvar, o SES devolve três registros CNAME do Easy DKIM para você publicar no seu DNS. São eles que provam a posse do domínio e assinam as mensagens:
Publique os três no seu provedor de DNS e espere. A AWS avisa que pode levar até 72 horas, mas na prática costuma ser bem mais rápido — o meu verificou em poucos minutos. ⏱️
📬 O MX que ninguém avisa que falta
Aqui foi meu primeiro tombo, e assumo: eu verifiquei o domínio, criei a regra toda bonitinha, mandei um e-mail de teste... e nada chegou. Fiquei olhando para o painel achando que a regra estava errada. 😳
Não estava. É que verificar o domínio não redireciona e-mail nenhum. Para o SES receber, o seu domínio precisa de um registro MX apontando para o endpoint de recebimento da região onde você criou a regra:
Tipo Nome Valor
MX teste1.hogsmeade.com.br 10 inbound-smtp.us-east-1.amazonaws.com
Repare no detalhe cruel: o endereço tem a região
dentro dele. Se você criou a regra em us-east-1 e apontou o MX
para outra região, o e-mail chega num SES que não tem regra nenhuma e é
simplesmente descartado — sem erro, sem log, sem nada. 🙃
E não confunda com o feedback-smtp do MAIL FROM
personalizado, que aparece no mesmo painel: aquele é para bounces de
envio. O de recebimento é o inbound-smtp.
📢 O tópico do SNS e a regra de recebimento
Com o domínio verificado e o MX no lugar, vá em Recebimento de e-mails e crie um conjunto de regras. Dentro dele, uma regra — e na etapa Adicionar ações, escolha Publicar em tópico do Amazon SNS.
Dá para criar o tópico ali mesmo, sem sair da tela, pelo botão Criar tópico do SNS:
Duas escolhas nessa tela merecem atenção:
Codificação. Eu deixei UTF-8 porque quero ler o e-mail direto no JSON, sem decodificar nada. A alternativa é Base64, que preserva qualquer caractere estranho mas obriga você a decodificar antes de olhar. Para acentuação normal em português, o UTF-8 resolve.
O limite de 150 KB. Está escrito bem ali na tela, e é fácil não ler: o SNS só carrega e-mails de até 150 KB, cabeçalho incluído. Mensagem maior que isso é devolvida ao remetente. Um e-mail com anexo estoura isso fácil — para esse caso o caminho é gravar no S3 em vez de publicar no SNS, mas isso já é assunto de outro dia.
Depois de criada, falta um passo que eu quase esqueci: o conjunto de regras precisa estar Ativo. Só um conjunto pode estar ativo por vez na conta, e um recém-criado nasce inativo:
🔗 Inscrevendo o webhook no tópico
Agora a parte que liga a AWS ao seu código. No painel do SNS, abra o tópico, clique em Criar assinatura e preencha:
- Protocolo: HTTPS
- Endpoint: a URL do seu webhook
Para testar sem subir nada, o webhook.site é perfeito: ele te dá uma URL descartável e mostra na tela tudo que chega nela. Foi o que eu usei para enxergar o formato antes de escrever uma linha de código.
✋ O link que você precisa clicar (ou o SNS nunca manda nada)
Segundo tombo, e esse é o mais importante do artigo. Se você só for ler uma seção, leia esta. 🙏
Assim que você cria a assinatura, o SNS não começa a
mandar as mensagens. Ele manda uma só, do tipo
SubscriptionConfirmation, e fica esperando. A assinatura
aparece como Pendente no painel e nada mais acontece.
Foi isso que chegou no webhook.site na hora em que criei a assinatura:
Repare no cabeçalho x-amz-sns-message-type: é ele que diz
que tipo de mensagem chegou. E no corpo tem o campo que importa, o
SubscribeURL:
{
"Type": "SubscriptionConfirmation",
"MessageId": "11111111-2222-3333-4444-555555555555",
"Token": "EXEMPLO0000000000000000000000000000",
"TopicArn": "arn:aws:sns:us-east-1:123456789012:teste-de-emails",
"Message": "You have chosen to subscribe to the topic ... To confirm the subscription, visit the SubscribeURL included in this message.",
"SubscribeURL": "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=...&Token=EXEMPLO...",
"Timestamp": "2026-08-10T17:18:06.696Z",
"SignatureVersion": "1"
}
Esse link precisa ser acessado. É assim que a AWS confirma que quem controla aquela URL concorda em receber as mensagens — senão qualquer um inscreveria o seu servidor num tópico e você viraria alvo de tráfego alheio.
Você pode abrir a URL no navegador (foi o que eu fiz no teste), ou deixar o seu código fazer isso sozinho. A segunda opção é melhor, porque se um dia a assinatura cair o webhook se reinscreve sem você perceber. 😌
Confirmado, o painel do SNS mostra o status verdinho:
Status: Confirmado
Endpoint: https://seu-dominio.com.br/webhook
Protocolo: HTTPS
📨 O que chega quando um e-mail cai
Com a assinatura confirmada, mande um e-mail para o seu domínio. O que chega no webhook é isto (com os identificadores trocados pelos fictícios):
{
"notificationType": "Received",
"mail": {
"timestamp": "2026-08-10T17:48:06.813Z",
"source": "[email protected]",
"messageId": "AMAZON_SES_SETUP_NOTIFICATION",
"destination": ["[email protected]"]
},
"receipt": {
"timestamp": "2026-08-10T17:48:06.813Z",
"processingTimeMillis": 0,
"recipients": ["[email protected]"],
"spamVerdict": { "status": "PASS" },
"virusVerdict": { "status": "PASS" },
"spfVerdict": { "status": "PASS" },
"dkimVerdict": { "status": "PASS" },
"action": {
"type": "SNS",
"topicArn": "arn:aws:sns:us-east-1:123456789012:teste-de-emails",
"encoding": "UTF8"
}
},
"content": "Date: Mon, 10 Aug 2026 17:48:06 +0000\r\nTo: [email protected]\r\nFrom: Amazon Web Services <[email protected]>\r\nSubject: Amazon SES Setup Notification\r\n\r\nHello,\r\n"
}
Traduzindo os campos que você vai usar de verdade:
Campo O que e
--------------------- --------------------------------------------
mail.source quem mandou
mail.destination para quem foi (e uma lista)
mail.timestamp quando chegou
receipt.spamVerdict passou no antispam? PASS ou FAIL
receipt.virusVerdict passou no antivirus? PASS ou FAIL
receipt.spfVerdict o remetente e mesmo daquele dominio?
receipt.dkimVerdict a assinatura do remetente confere?
content o e-mail inteiro, cru, do jeito que chegou
Na especificação da AWS esses campos aparecem como source,
destination, spamVerdict,
virusVerdict, spfVerdict,
dkimVerdict e content — é por esses nomes que
você procura na documentação.
E aqui vai o detalhe que me pegou: não existe um campo
assunto. O assunto está lá dentro do
content, no meio do e-mail cru, numa linha que começa com
Subject:. Se você quiser o assunto, tem que ir buscar. 🔍
Outro detalhe que vale ouro: os quatro veredictos
(spam, virus, spf, dkim)
já vêm calculados pela AWS. Você recebe de graça a informação de que aquela
mensagem é suspeita — é só olhar antes de processar.
🎣 O webhook que recebe tudo isso
Agora o código. Node puro, sem instalar nada — http e
https já vêm na caixa:
// Recebe as notificacoes do SNS com os e-mails que chegaram no SES.
const http = require("http");
const https = require("https");
const PORTA = 3009;
// O SNS manda o JSON como text/plain, entao nao da para usar body parser
// que so aceita application/json. Aqui juntamos os pedacos na mao.
function lerCorpo(requisicao) {
return new Promise(function (resolve, reject) {
let corpo = "";
requisicao.on("data", function (pedaco) {
corpo = corpo + pedaco;
});
requisicao.on("end", function () {
resolve(corpo);
});
requisicao.on("error", reject);
});
}
// Para o SNS comecar a mandar as mensagens, e preciso visitar a SubscribeURL
// uma vez. Enquanto isso nao acontece, a assinatura fica "Pendente".
function confirmarInscricao(endereco) {
return new Promise(function (resolve, reject) {
https.get(endereco, function (resposta) {
resposta.resume();
resolve(resposta.statusCode);
}).on("error", reject);
});
}
function lerEmailRecebido(aviso) {
const remetente = aviso.mail.source;
const destinatarios = aviso.mail.destination.join(", ");
// O assunto nao vem em campo proprio: esta dentro do e-mail bruto,
// no campo "content". Procuramos a linha que comeca com "Subject:".
let assunto = "(sem assunto)";
const linhas = aviso.content.split("\r\n");
for (let i = 0; i < linhas.length; i++) {
if (linhas[i].indexOf("Subject:") === 0) {
assunto = linhas[i].substring(8).trim();
break;
}
}
const spam = aviso.receipt.spamVerdict.status;
const virus = aviso.receipt.virusVerdict.status;
return {
de: remetente,
para: destinatarios,
assunto: assunto,
spam: spam,
virus: virus
};
}
const servidor = http.createServer(async function (requisicao, resposta) {
if (requisicao.method !== "POST") {
resposta.writeHead(405);
resposta.end("Use POST");
return;
}
try {
const corpo = await lerCorpo(requisicao);
const dados = JSON.parse(corpo);
// O tipo vem no cabecalho x-amz-sns-message-type. Da para ler o campo
// "Type" do JSON tambem, mas ele some quando a entrega bruta esta
// ligada -- ai so o cabecalho sobra.
const tipo = requisicao.headers["x-amz-sns-message-type"];
if (tipo === "SubscriptionConfirmation") {
console.log("Confirmando a inscricao no topico...");
const codigo = await confirmarInscricao(dados.SubscribeURL);
console.log("SNS respondeu " + codigo + " -- inscricao confirmada.");
} else if (tipo === "Notification") {
// Sem entrega bruta o e-mail vem como texto dentro de "Message",
// e precisa de um segundo JSON.parse.
const aviso = JSON.parse(dados.Message);
const email = lerEmailRecebido(aviso);
console.log("Chegou e-mail:");
console.log(" De.......: " + email.de);
console.log(" Para.....: " + email.para);
console.log(" Assunto..: " + email.assunto);
console.log(" Spam.....: " + email.spam + " Virus: " + email.virus);
} else {
// Com a entrega bruta ligada o SNS manda o JSON do SES direto,
// sem envelope nenhum.
const email = lerEmailRecebido(dados);
console.log("Chegou e-mail (entrega bruta):");
console.log(" De.......: " + email.de);
console.log(" Para.....: " + email.para);
console.log(" Assunto..: " + email.assunto);
console.log(" Spam.....: " + email.spam + " Virus: " + email.virus);
}
// Responda 200 rapido. Se demorar mais de 15 segundos o SNS
// considera que falhou e manda tudo de novo.
resposta.writeHead(200);
resposta.end("ok");
} catch (erro) {
console.error("Falhou ao tratar a notificacao: " + erro.message);
resposta.writeHead(200);
resposta.end("ok");
}
});
servidor.listen(PORTA, function () {
console.log("Webhook ouvindo na porta " + PORTA);
});
Três pontos desse arquivo merecem explicação, porque cada um evita um bug que eu levei um tempo para entender:
1. O SNS manda o JSON como text/plain.
Isso mesmo — o conteúdo é JSON, mas o cabeçalho diz texto puro. Se você usa
Express com express.json(), ele ignora o corpo porque o
Content-Type não bate, e você recebe um objeto vazio sem erro
nenhum. Por isso aqui eu junto os pedaços na mão e chamo
JSON.parse depois.
2. O Message é um JSON dentro de outro
JSON. Quando a entrega bruta está desligada, o e-mail vem como
texto dentro do campo Message do envelope do SNS —
então são dois JSON.parse, um dentro do
outro. 🤯 Esquecer o segundo é o erro clássico: você fica tentando ler
dados.mail.source e recebe undefined.
3. Responda 200 rápido. O SNS espera a sua resposta e
desiste depois de 15 segundos, tratando como falha e mandando tudo de novo.
Repare que até no catch eu respondo 200: se o
problema é no meu código, reenviar não vai consertar — só vai me dar o
mesmo erro repetido várias vezes. Melhor registrar a falha e seguir a
vida. 😅
▶️ Rodando
Subindo o webhook e mandando as três situações — a confirmação da inscrição, um e-mail com o envelope do SNS e um e-mail com a entrega bruta ligada — a saída é esta:
Webhook ouvindo na porta 3009
Confirmando a inscricao no topico...
SNS respondeu 200 -- inscricao confirmada.
Chegou e-mail:
De.......: [email protected]
Para.....: [email protected]
Assunto..: Amazon SES Setup Notification
Spam.....: PASS Virus: PASS
Chegou e-mail (entrega bruta):
De.......: [email protected]
Para.....: [email protected]
Assunto..: Testando o webhook
Spam.....: PASS Virus: PASS
Repare que os dois formatos caem no mesmo lugar e imprimem a mesma coisa — que era exatamente a ideia de tratar os dois casos. A primeira linha é a confirmação da inscrição acontecendo sozinha, sem eu abrir o link no navegador. 🎉
Um detalhe para quando for para valer: o SNS só entrega em HTTPS com certificado válido. Certificado autoassinado ele recusa, e a assinatura fica pendente para sempre sem dizer o motivo. Na sua máquina, para desenvolver, o jeito é usar um túnel que já te dá HTTPS pronto.
🧾 O resumo dos tropeços
Se alguma coisa não estiver chegando, olhe nesta ordem — é a ordem em que eu me atrapalhei:
1. O registro MX existe e aponta para inbound-smtp da regiao certa?
2. O conjunto de regras esta ATIVO? (so um pode estar por vez)
3. A regra esta habilitada?
4. A assinatura do SNS esta Confirmada, e nao Pendente?
5. O e-mail tem menos de 150 KB?
6. Seu endpoint responde 200 em menos de 15 segundos?
Os itens 1 e 4 respondem por quase tudo. E os dois falham silenciosamente — nenhum dos dois te avisa que está errado, o e-mail simplesmente não chega. Que é o pior tipo de erro que existe. 😤
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
Por que o e-mail não chega no SES mesmo com o domínio verificado?
MX apontando para o endpoint de recebimento da região onde a regra foi criada, por exemplo 10 inbound-smtp.us-east-1.amazonaws.com. Se a região do MX for diferente da região da regra, a mensagem é descartada sem erro, sem log e sem nada.Por que o SNS não manda nada depois de eu criar a assinatura?
SubscriptionConfirmation e fica esperando que alguém acesse o link do campo SubscribeURL. Você pode abrir a URL no navegador ou deixar o próprio código fazer isso, o que é melhor porque o webhook se reinscreve sozinho se a assinatura cair.Qual o tamanho máximo de e-mail que o SNS entrega?
Por que o Express não lê o corpo da notificação do SNS?
Content-Type: text/plain. O express.json() só trata corpos declarados como application/json, então ele ignora o conteúdo e você recebe um objeto vazio, sem erro nenhum. A saída é juntar os pedaços do corpo na mão e chamar JSON.parse depois.Onde está o assunto do e-mail no JSON que o SES manda?
content, no meio do e-mail cru, numa linha que começa com Subject:, então você precisa procurar essa linha. O que vem em campo próprio é mail.source, mail.destination, mail.timestamp e os quatro veredictos de spam, vírus, SPF e DKIM já calculados pela AWS.Preciso responder rápido ao SNS? E o certificado?
200 até no catch, porque se o problema é do seu código o reenvio só repete o mesmo erro. E o SNS só entrega em HTTPS com certificado válido: certificado autoassinado ele recusa, e a assinatura fica pendente para sempre sem dizer o motivo.Leia também
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Resend: enviando e recebendo e-mails com Node.js
Tutorial da Resend com Node.js: criar a chave, enviar com fetch, verificar o domínio e receber e-mails por webhook conferindo a assinatura.
Node.js: testando scripts de scraping no ScrapingCourse
O ScrapingCourse é um site feito para treinar scraping. Cinco desafios dele em Node.js, e a armadilha real que cada um esconde.