Node.js: autenticando um remetente no AWS SES
Olá meus Unicórnios! 🦄✨
Sabe aquele formulário de contato que manda o e-mail para o dono do
site? 📬 Pois é. Ele funciona lindamente enquanto o remetente é o seu
próprio endereço. Mas aí chega o pedido: "quero que o e-mail chegue
como se viesse do [email protected]". E é aí que a
brincadeira muda de figura.
Porque você não pode simplesmente escrever o endereço de outra pessoa
no campo from e sair enviando. Se pudesse, qualquer um mandaria
e-mail em nome do seu banco — e é exatamente por isso que o AWS SES exige
que o endereço seja verificado antes. O dono da caixa
precisa clicar num link e dizer "sim, autorizo".
Neste artigo eu mostro o caminho inteiro: pedir a verificação de um e-mail pelo SDK do Node.js, consultar em que pé ela está, e enviar uma mensagem usando esse endereço como remetente. Um arquivo só, quatro comandos, e as armadilhas que eu levei um tempo para entender. 🙏
🔑 Antes de tudo: a credencial não mora no código
Vou começar pelo que mais me incomoda ver por aí. Você vai precisar de uma Access Key e de uma Secret Access Key da AWS, criadas no IAM com permissão para o SES. E elas não entram no seu arquivo. Nunca. Nem "só para testar", nem "depois eu tiro".
Uma chave secreta commitada num repositório é uma chave vazada — mesmo que você a apague no commit seguinte, ela continua no histórico do git para sempre. Então o script lê tudo do ambiente:
// Credencial NUNCA fica no codigo: ela vem do ambiente.
const REGIAO = process.env.AWS_REGIAO || 'us-east-1';
const CHAVE = process.env.AWS_ACCESS_KEY_ID;
const SEGREDO = process.env.AWS_SECRET_ACCESS_KEY;
E para instalar a única dependência que este tutorial tem:
npm install @aws-sdk/client-ses
🌎 A região faz parte da identidade
Essa foi a primeira pedrinha no meu sapato, e ela é sorrateira porque não dá erro nenhum — só um resultado que não faz sentido. 😅
Um e-mail verificado em us-east-1 não existe
em sa-east-1. São cadastros separados. Se você pede a
verificação numa região, clica no link bonitinho, e depois tenta enviar
apontando para outra, o SES vai te dizer que o endereço não está
verificado. E você vai jurar que está, porque acabou de clicar.
Por isso o cliente é montado num lugar só, e todos os comandos usam essa mesma função:
function criarCliente() {
if (!CHAVE || !SEGREDO) {
throw new Error('Defina AWS_ACCESS_KEY_ID e AWS_SECRET_ACCESS_KEY no ambiente.');
}
const opcoes = {
region: REGIAO,
credentials: { accessKeyId: CHAVE, secretAccessKey: SEGREDO },
};
// A regiao faz parte da identidade: um e-mail verificado em us-east-1
// nao existe em sa-east-1. Verifique e envie sempre na mesma regiao.
if (process.env.AWS_ENDPOINT) {
opcoes.endpoint = process.env.AWS_ENDPOINT;
}
return new SESClient(opcoes);
}
Repare no if da credencial logo na primeira linha. Sem ele,
o SDK monta o cliente feliz da vida com undefined no lugar da
chave, e o erro só aparece lá na frente, na hora do envio, com uma mensagem
que fala de assinatura e não fala de credencial faltando. Falhar cedo, com
a mensagem certa, economiza meia hora de investigação.
✉️ Pedindo a verificação do endereço
O comando é curtinho. Você diz o endereço, e a Amazon manda para a caixa de entrada dele um e-mail com um link de confirmação:
// Pede a Amazon que mande o e-mail de confirmacao para o endereco.
async function verificarRemetente(email) {
const cliente = criarCliente();
const comando = new VerifyEmailIdentityCommand({ EmailAddress: email });
await cliente.send(comando);
console.log('Pedido de verificacao enviado para ' + email);
console.log('Abra a caixa de entrada e clique no link da Amazon.');
}
Aqui vem a parte que me pegou de jeito: esse comando devolve sucesso mesmo que ninguém nunca clique no link. 🤯 Ele só quer dizer "pedido registrado, e-mail disparado". Não quer dizer "endereço autorizado".
Eu passei um tempo achando que o fluxo tinha terminado ali. Tinha não — tinha começado. Quem autoriza de verdade é o dono da caixa, com o clique dele, e isso acontece fora do seu código, num tempo que você não controla. Pode ser em dez segundos ou em três dias.
Ou seja: o seu sistema precisa conviver com um remetente que está pendente. E é por isso que o próximo comando é o mais importante de todos.
🔍 Consultando o status — e o mapa vazio
Se você só for ler um pedaço deste artigo, leia este. 🙏 É aqui que mora a armadilha que mais me custou tempo.
// Consulta em que pe esta a verificacao.
async function consultarStatus(email) {
const cliente = criarCliente();
const comando = new GetIdentityVerificationAttributesCommand({ Identities: [email] });
const resposta = await cliente.send(comando);
// Endereco que nunca foi pedido NAO vem como erro: vem como um mapa
// vazio. Sem este if o codigo estoura em cima de undefined.
if (!resposta.VerificationAttributes || !resposta.VerificationAttributes[email]) {
return 'NaoIniciado';
}
return resposta.VerificationAttributes[email].VerificationStatus;
}
Repare no comentário. Quando você pergunta pelo status de um endereço
que nunca foi cadastrado, o SES não devolve erro, não
devolve 404, não reclama de nada. Ele devolve uma resposta bem-sucedida com
o mapa VerificationAttributes vazio.
Parece detalhe bobo, mas o efeito prático é cruel: sem aquele
if, a linha seguinte tenta ler .VerificationStatus
de undefined e o seu processo morre com um
TypeError. E o pior — ele morre justamente no caso mais banal
do mundo, que é alguém digitar um e-mail que ainda não foi cadastrado. 😳
Este if não é firula defensiva. Ele é a diferença entre
"esse endereço ainda não foi iniciado" e uma exceção não tratada derrubando
a requisição.
🏷️ Traduzindo os cinco estados
O SES devolve o status em inglês e com nomes que não dizem muito para quem está do outro lado da tela. Eu traduzo antes de mostrar:
function traduzirStatus(status) {
if (status === 'Success') {
return 'verificado';
}
if (status === 'Pending') {
return 'aguardando o clique no link';
}
if (status === 'Failed') {
return 'falhou';
}
if (status === 'TemporaryFailure') {
return 'falha temporaria';
}
return 'nao iniciado';
}
Vale entender o que cada um significa de verdade:
- verificado — o link foi clicado. É o único estado em que você pode enviar por esse endereço.
- aguardando o clique no link — o pedido foi feito, o e-mail saiu, ninguém clicou ainda. O estado mais comum, e o que o seu sistema mais vai encontrar.
- falhou — a verificação não foi concluída. O link do SES expira em 24 horas; passou disso, é pedir de novo.
- falha temporária — problema do lado da Amazon ao tentar entregar o e-mail de confirmação. Tentar de novo costuma resolver.
- não iniciado — este não vem da AWS: é o nosso, o do mapa vazio que expliquei acima.
Os nomes originais em inglês, para quem for consultar a documentação da
AWS, são Success, Pending, Failed e
TemporaryFailure.
📮 Enviando — e conferindo antes
Agora o envio. E ele começa com uma consulta, não com o envio:
async function enviarEmail(remetente, destinatario) {
const status = await consultarStatus(remetente);
// Enviar por um remetente ainda pendente devolve erro da AWS e some
// com a mensagem. Conferir antes custa uma chamada e evita o susto.
if (status !== 'Success') {
throw new Error('O remetente ' + remetente + ' esta ' + traduzirStatus(status) + '.');
}
const cliente = criarCliente();
const comando = new SendEmailCommand({
Source: remetente,
Destination: { ToAddresses: [destinatario] },
Message: {
Subject: { Data: 'Teste de remetente autenticado', Charset: 'UTF-8' },
Body: {
Html: { Data: '<p>Enviado pelo SES com remetente proprio.</p>', Charset: 'UTF-8' },
},
},
});
const resposta = await cliente.send(comando);
console.log('Mensagem enviada. Identificador: ' + resposta.MessageId);
}
Aquela consulta antes do envio parece desperdício de uma chamada de API — e é de propósito. Sem ela, o que acontece quando o remetente está pendente é o SES recusar o envio com um erro genérico, e a sua mensagem simplesmente sumir. O formulário do seu cliente responde "enviado com sucesso" e o e-mail nunca chega em lugar nenhum.
Com a consulta, o erro é claro, chega antes, e diz exatamente o que falta fazer: clicar no link. Uma chamada a mais vale muito mais barato que um lead perdido em silêncio. 💸
Repare também no Charset: 'UTF-8' nos dois lugares — no
assunto e no corpo. Esqueça um deles e todo "ç" e "ã" do seu texto vira
caractere quebrado na caixa de entrada de quem recebe. O SES não adivinha
o encoding; ele usa o que você mandar.
🧹 Removendo o remetente
Quando o cliente troca de endereço ou cancela o serviço, o cadastro antigo fica lá, ocupando espaço na sua lista de identidades. Limpar é uma linha:
async function removerRemetente(email) {
const cliente = criarCliente();
const comando = new DeleteIdentityCommand({ Identity: email });
await cliente.send(comando);
console.log('Remetente removido do SES: ' + email);
}
Uma observação de quem já se atrapalhou com isso: se você estiver
removendo o remetente dentro de um fluxo maior — apagando um cadastro no
seu banco, por exemplo — vale envolver essa chamada num try
próprio e seguir em frente se ela falhar. O importante daquele fluxo é
limpar o seu lado; uma identidade órfã na AWS é sujeira, não é bug. Falhar
a operação inteira por causa dela deixa o usuário sem conseguir apagar o
cadastro.
🖥️ O script inteiro
É um arquivo só, lido de cima para baixo. Sem classe, sem camada, sem padrão de projeto — dá para copiar, colar e rodar:
// Autenticando um remetente no AWS SES e enviando por ele.
// Rode: node remetente.js verificar [email protected]
// node remetente.js status [email protected]
// node remetente.js enviar [email protected] [email protected]
// node remetente.js remover [email protected]
const {
SESClient,
VerifyEmailIdentityCommand,
GetIdentityVerificationAttributesCommand,
SendEmailCommand,
DeleteIdentityCommand,
} = require('@aws-sdk/client-ses');
// Credencial NUNCA fica no codigo: ela vem do ambiente.
const REGIAO = process.env.AWS_REGIAO || 'us-east-1';
const CHAVE = process.env.AWS_ACCESS_KEY_ID;
const SEGREDO = process.env.AWS_SECRET_ACCESS_KEY;
function criarCliente() {
if (!CHAVE || !SEGREDO) {
throw new Error('Defina AWS_ACCESS_KEY_ID e AWS_SECRET_ACCESS_KEY no ambiente.');
}
const opcoes = {
region: REGIAO,
credentials: { accessKeyId: CHAVE, secretAccessKey: SEGREDO },
};
// A regiao faz parte da identidade: um e-mail verificado em us-east-1
// nao existe em sa-east-1. Verifique e envie sempre na mesma regiao.
if (process.env.AWS_ENDPOINT) {
opcoes.endpoint = process.env.AWS_ENDPOINT;
}
return new SESClient(opcoes);
}
// Pede a Amazon que mande o e-mail de confirmacao para o endereco.
async function verificarRemetente(email) {
const cliente = criarCliente();
const comando = new VerifyEmailIdentityCommand({ EmailAddress: email });
await cliente.send(comando);
console.log('Pedido de verificacao enviado para ' + email);
console.log('Abra a caixa de entrada e clique no link da Amazon.');
}
// Consulta em que pe esta a verificacao.
async function consultarStatus(email) {
const cliente = criarCliente();
const comando = new GetIdentityVerificationAttributesCommand({ Identities: [email] });
const resposta = await cliente.send(comando);
// Endereco que nunca foi pedido NAO vem como erro: vem como um mapa
// vazio. Sem este if o codigo estoura em cima de undefined.
if (!resposta.VerificationAttributes || !resposta.VerificationAttributes[email]) {
return 'NaoIniciado';
}
return resposta.VerificationAttributes[email].VerificationStatus;
}
function traduzirStatus(status) {
if (status === 'Success') {
return 'verificado';
}
if (status === 'Pending') {
return 'aguardando o clique no link';
}
if (status === 'Failed') {
return 'falhou';
}
if (status === 'TemporaryFailure') {
return 'falha temporaria';
}
return 'nao iniciado';
}
async function enviarEmail(remetente, destinatario) {
const status = await consultarStatus(remetente);
// Enviar por um remetente ainda pendente devolve erro da AWS e some
// com a mensagem. Conferir antes custa uma chamada e evita o susto.
if (status !== 'Success') {
throw new Error('O remetente ' + remetente + ' esta ' + traduzirStatus(status) + '.');
}
const cliente = criarCliente();
const comando = new SendEmailCommand({
Source: remetente,
Destination: { ToAddresses: [destinatario] },
Message: {
Subject: { Data: 'Teste de remetente autenticado', Charset: 'UTF-8' },
Body: {
Html: { Data: '<p>Enviado pelo SES com remetente proprio.</p>', Charset: 'UTF-8' },
},
},
});
const resposta = await cliente.send(comando);
console.log('Mensagem enviada. Identificador: ' + resposta.MessageId);
}
async function removerRemetente(email) {
const cliente = criarCliente();
const comando = new DeleteIdentityCommand({ Identity: email });
await cliente.send(comando);
console.log('Remetente removido do SES: ' + email);
}
async function main() {
const acao = process.argv[2];
const email = process.argv[3];
const destino = process.argv[4];
if (!acao || !email) {
console.log('Uso: node remetente.js verificar|status|enviar|remover <email> [destino]');
return;
}
try {
if (acao === 'verificar') {
await verificarRemetente(email);
} else if (acao === 'status') {
const status = await consultarStatus(email);
console.log(email + ': ' + traduzirStatus(status) + ' (' + status + ')');
} else if (acao === 'enviar') {
if (!destino) {
console.log('Informe o destinatario.');
return;
}
await enviarEmail(email, destino);
} else if (acao === 'remover') {
await removerRemetente(email);
} else {
console.log('Acao desconhecida: ' + acao);
}
} catch (erro) {
console.error('Erro: ' + erro.message);
process.exitCode = 1;
}
}
main();
▶️ Rodando os quatro comandos
Com as variáveis de ambiente no lugar, o uso é direto. Primeiro, pedir a verificação:
node remetente.js verificar [email protected]
Pedido de verificacao enviado para [email protected]
Abra a caixa de entrada e clique no link da Amazon.
Logo depois, o status ainda é o de quem espera o clique:
[email protected]: aguardando o clique no link (Pending)
E é aqui que aquela conferência antes do envio mostra o valor dela. Tentando enviar por esse remetente ainda pendente:
node remetente.js enviar [email protected] [email protected]
Erro: O remetente [email protected] esta aguardando o clique no link.
Mensagem clara, código de saída 1, e nenhuma mensagem perdida no caminho. 🎯 Já com um endereço que passou pelo clique:
Mensagem enviada. Identificador: 0100018f-fakemsg-0001
E o endereço que nunca foi cadastrado — aquele do mapa vazio — responde sem estourar:
[email protected]: nao iniciado (NaoIniciado)
🎓 O que eu tiro disso
O fluxo do SES parece burocrático, e é mesmo. Mas a burocracia toda existe por um motivo bem simples: o único jeito de garantir que você pode enviar em nome de um endereço é perguntar ao dono dele. Todo o resto do código deste artigo é consequência disso.
As três coisas que eu guardaria: o pedido de verificação não é a verificação; o mapa vazio de um endereço desconhecido não é um erro; e conferir o status antes de enviar custa uma chamada e evita uma mensagem sumindo sem deixar rastro.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.