Pular para o conteúdo
AWS

AWS SES: recebendo e-mails e mandando pra um webhook

Uma coruja magica de correio entrega um envelope luminoso cujo feixe de luz aponta para um pergaminho com um gancho dourado, ao lado de um unicornio e uma caixa de correio de pedra encantada

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:

Tela Criar identidade do Amazon SES com o tipo Dominio selecionado e o campo Dominio preenchido com teste1.hogsmeade.com.br

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:

Painel do SES mostrando os tres registros CNAME do Easy DKIM gerados para o dominio, com os nomes terminados em _domainkey

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:

Etapa Adicionar acoes do SES com a acao Publicar em topico do Amazon SNS, o ARN do topico preenchido e a codificacao UTF-8 selecionada

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:

Painel do conjunto de regras de recebimento com o status Ativo e a regra habilitada na posicao 1

🔗 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:

Tela do webhook.site mostrando a requisicao POST recebida do Amazon SNS com o tipo SubscriptionConfirmation, o user-agent Amazon Simple Notification Service Agent e os valores sensiveis cobertos por tarjas pretas

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?
Verificar o domínio não redireciona e-mail nenhum: os três CNAMEs do Easy DKIM servem para provar a identidade e assinar mensagens de envio. Para o SES receber, o domínio precisa de um registro 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?
Porque a assinatura fica em estado Pendente até ser confirmada. Ao criar a assinatura, o SNS manda uma única requisição do tipo 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?
O SNS só carrega e-mails de até 150 KB, cabeçalho incluído. Mensagem maior que isso é devolvida ao remetente, e um e-mail com anexo estoura esse limite fácil. Para esse caso o caminho é gravar a mensagem no S3 em vez de publicar no SNS.
Por que o Express não lê o corpo da notificação do SNS?
Porque o SNS manda o JSON com 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?
Não existe campo de assunto. O assunto está dentro do campo 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?
Sim: o SNS espera a sua resposta e desiste depois de 15 segundos, tratando como falha e reenviando tudo de novo. Vale responder 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