This content originally appeared on DEV Community and was authored by Cláudio Filipe Lima Rapôso
Quero te levar do zero até o primeiro e-mail de verificação enviado pela AWS, usando Terraform para criar a infra e AWS Lambda (Node.js com AWS SDK v3) para enviar. A ideia é simples: você provisiona tudo como código, empacota uma pequena função de envio e testa em minutos.
Observação importante sobre o SES: contas novas começam no sandbox. Nesse modo, você só consegue enviar para endereços verificados (remetente e destinatário) e com limites mais baixos. Para enviar para qualquer pessoa, é preciso solicitar saída do sandbox. Vou te mostrar como validar identidades e como testar mesmo no sandbox. (AWS Documentation)
O problema que essa solução resolve
Quase toda app precisa confirmar se um e-mail realmente pertence ao usuário. Em vez de acoplar envio de e-mail no backend principal, vamos isolar a responsabilidade em uma Lambda enxuta, com boas práticas de engenharia (SOLID/DRY, separação de camadas) e infra como código. Resultado: baixo acoplamento, escala automática e governança simples.
O que vamos montar
- Um Email Identity no Amazon SES (o remetente).
- Uma IAM Role mínima para a Lambda.
- Uma Lambda em Node.js 20 que usa AWS SDK v3 (SESv2) para enviar.
- Opcional: um Function URL para chamar a Lambda por HTTP e testar com
curl
/Postman. (Terraform Registry)
Pré-requisitos
- Conta AWS com
aws configure
pronto. - Terraform ≥ 1.5.
- Node.js 20 + npm.
- Região do SES suportada (ex.:
us-east-1
).
Estrutura de pastas
ses-verify/
app/
package.json
tsconfig.json
src/
config.ts
dto.ts
email/
SesEmailClient.ts
EmailService.ts
handler.ts
build/ # gerado pelo script
infra/
main.tf
variables.tf
outputs.tf
Passo 1 — Infra com Terraform (SES, Lambda, IAM e Function URL)
Crie os arquivos abaixo em infra/
.
variables.tf
variable "project_name" {
type = string
default = "ses-verify"
}
variable "aws_region" {
type = string
default = "us-east-1"
}
variable "sender_email" {
type = string
}
variable "lambda_package_path" {
type = string
default = "../build/lambda.zip"
}
variable "expose_function_url" {
type = bool
default = false
}
variable "function_url_auth_type" {
type = string
default = "NONE"
}
main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_sesv2_email_identity" "sender" {
email_identity = var.sender_email
}
data "aws_iam_policy_document" "lambda_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "lambda" {
name = "${var.project_name}-role"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy_attachment" "basic_logs" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "ses_send" {
statement {
effect = "Allow"
actions = ["ses:SendEmail"]
resources = ["*"]
}
}
resource "aws_iam_role_policy" "ses_send" {
name = "${var.project_name}-ses-send"
role = aws_iam_role.lambda.id
policy = data.aws_iam_policy_document.ses_send.json
}
resource "aws_lambda_function" "send_verification" {
function_name = "${var.project_name}-send"
role = aws_iam_role.lambda.arn
handler = "dist/handler.handler"
filename = var.lambda_package_path
source_code_hash = filebase64sha256(var.lambda_package_path)
runtime = "nodejs20.x"
timeout = 10
environment {
variables = {
SENDER_EMAIL = var.sender_email
SES_REGION = var.aws_region
}
}
}
resource "aws_lambda_function_url" "this" {
count = var.expose_function_url ? 1 : 0
function_name = aws_lambda_function.send_verification.function_name
authorization_type = var.function_url_auth_type
cors {
allow_origins = ["*"]
allow_methods = ["POST", "OPTIONS"]
allow_headers = ["content-type"]
}
}
Refs úteis:
aws_lambda_function
,aws_lambda_function_url
e política básica de logs para Lambda. (Terraform Registry, AWS Documentation)
outputs.tf
output "lambda_function_name" {
value = aws_lambda_function.send_verification.function_name
}
output "lambda_function_arn" {
value = aws_lambda_function.send_verification.arn
}
output "function_url" {
value = try(aws_lambda_function_url.this[0].function_url, null)
description = "URL pública opcional para testes"
}
Passo 2 — Lambda em Node.js (AWS SDK v3 / SESv2)
Abaixo, um design simples, mas com separação de responsabilidades:
-
SesEmailClient
encapsula o client do SESv2. -
EmailService
define o caso de uso de envio de verificação. -
config
centraliza variáveis de ambiente. -
handler
expõe a função Lambda.
app/package.json
{
"name": "ses-verify-lambda",
"version": "1.0.0",
"private": true,
"main": "dist/handler.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"package": "mkdir -p ../build && zip -r ../build/lambda.zip dist node_modules package.json"
},
"dependencies": {
"@aws-sdk/client-sesv2": "^3.600.0"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.136",
"typescript": "^5.5.4"
}
}
app/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src"]
}
app/src/config.ts
export const env = {
region: process.env.SES_REGION || process.env.AWS_REGION || "us-east-1",
sender: process.env.SENDER_EMAIL || ""
};
app/src/dto.ts
export type SendVerificationInput = {
to: string;
code: string;
subject?: string;
link?: string;
};
app/src/email/SesEmailClient.ts
import { SESv2Client, SendEmailCommand, SendEmailCommandInput } from "@aws-sdk/client-sesv2";
export class SesEmailClient {
private readonly client: SESv2Client;
constructor(region: string) {
this.client = new SESv2Client({ region });
}
send(input: SendEmailCommandInput) {
return this.client.send(new SendEmailCommand(input));
}
}
app/src/email/EmailService.ts
import { SesEmailClient } from "./SesEmailClient";
import { SendVerificationInput } from "../dto";
export class EmailService {
constructor(private readonly ses: SesEmailClient, private readonly sender: string) {}
async sendVerification({ to, code, subject, link }: SendVerificationInput) {
if (!this.sender) throw new Error("Missing sender");
if (!to || !code) throw new Error("Invalid payload");
const subj = subject || "Verifique seu e-mail";
const html = this.buildHtml(code, link);
const text = this.buildText(code, link);
return this.ses.send({
FromEmailAddress: this.sender,
Destination: { ToAddresses: [to] },
Content: {
Simple: {
Subject: { Data: subj, Charset: "UTF-8" },
Body: {
Html: { Data: html, Charset: "UTF-8" },
Text: { Data: text, Charset: "UTF-8" }
}
}
}
});
}
private buildHtml(code: string, link?: string) {
const callToAction = link ? `<p><a href="${link}">Confirmar conta</a></p>` : "";
return `<h1>Seu código</h1><p>${code}</p>${callToAction}`;
}
private buildText(code: string, link?: string) {
const callToAction = link ? `\n\nConfirmar: ${link}` : "";
return `Seu código: ${code}${callToAction}`;
}
}
app/src/handler.ts
import { env } from "./config";
import { EmailService } from "./email/EmailService";
import { SesEmailClient } from "./email/SesEmailClient";
import { SendVerificationInput } from "./dto";
const service = new EmailService(new SesEmailClient(env.region), env.sender);
export const handler = async (event: any) => {
const body = typeof event?.body === "string" ? JSON.parse(event.body) : event;
const payload = body as SendVerificationInput;
await service.sendVerification(payload);
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true })
};
};
A operação usada é
SendEmail
do SESv2; no SDK v3, montamosDestination
,Content.Simple
eFromEmailAddress
como no exemplo acima. (AWS Documentation)
Passo 3 — Build do código e empacotamento
Dentro de app/
:
npm ci
npm run build
npm run package
Isso gera build/lambda.zip
, que o Terraform referenciará.
Passo 4 — Deploy com Terraform
Na raiz do projeto:
terraform -chdir=infra init
terraform -chdir=infra apply \
-var='aws_region=us-east-1' \
-var='sender_email=seu-remetente@exemplo.com' \
-var='lambda_package_path=../build/lambda.zip' \
-var='expose_function_url=true'
O SES vai enviar um e-mail de verificação para o remetente informado. Clique no link recebido para confirmar o Email Identity antes de testar o envio. Em sandbox, o destinatário também precisa estar verificado (ou use o mailbox simulator). Para liberar envio geral, faça o pedido de produção. (AWS Documentation)
Passo 5 — Testes
Opção A: invocar direto a Lambda (AWS CLI)
FUNC_NAME=$(terraform -chdir=infra output -raw lambda_function_name)
aws lambda invoke \
--function-name "$FUNC_NAME" \
--cli-binary-format raw-in-base64-out \
--payload '{"to":"destinatario@exemplo.com","code":"123456","subject":"Verifique seu e-mail","link":"https://minhaapp/verify?code=123456"}' \
/dev/stdout
Opção B: chamar via Function URL (curl/Postman)
Se você ativou expose_function_url=true
, capture a URL:
URL=$(terraform -chdir=infra output -raw function_url)
curl -s -X POST "$URL" -H 'content-type: application/json' \
-d '{"to":"destinatario@exemplo.com","code":"789012"}'
Function URLs são um atalho elegante para testar HTTP sem API Gateway. Para produção, considere autenticação (AWS_IAM), WAF/CloudFront e políticas mais restritivas. (Terraform Registry, Håkon Eriksen Drange – Perspectives)
Se tudo deu certo, o e-mail chegará no destino permitido pelo seu status do SES.
Próximos passos e boas práticas
- Sair do sandbox quando estiver pronto para enviar a qualquer domínio. (AWS Documentation)
- Domínio verificado + DKIM/DMARC para reputação e entregabilidade melhores (em vez de só endereço). (AWS Documentation)
- Templates e métricas com Configuration Sets, IP pools gerenciados, etc., se precisar escalar e observar. (Stack Overflow)
-
Segurança: se usar Function URL em produção, avalie
AWS_IAM
, WAF e CDN com origem na URL. (Håkon Eriksen Drange – Perspectives)
This content originally appeared on DEV Community and was authored by Cláudio Filipe Lima Rapôso