Pular para o conteúdo
AWS

Node.js: autenticando um remetente no AWS SES

Paloma Macetko
Ilustracao de uma coruja magica entregando um envelope com selo de cera aceso por uma varinha, ao lado de um unicornio guardando um portao de correio com cadeado de luz

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