Eu quero te levar do zero até uma solução funcional de verificação de e-mail, sem depender de servidores próprios. Vamos usar AWS SES para enviar os e-mails, DynamoDB para guardar tokens com TTL, e AWS Lambda (Node.js) para orquestrar tudo. A infraestrutura nasce com Terraform usando módulos separados (SES, DynamoDB, IAM, Lambda), mantendo o código limpo e fácil de evoluir.
O problema e a solução
Toda aplicação que cria contas precisa verificar e-mails. Fazer isso na unha costuma gerar acoplamento, scripts soltos e dor de cabeça com credenciais. Aqui eu:
- Provisiono a infraestrutura como código.
- Centralizo envios no SES com remetente verificado.
- Persisto tokens no DynamoDB com expiração automática via TTL.
-
Exponho uma Lambda com Function URL que:
-
POST /send
: gera token, salva no DynamoDB e envia e-mail de verificação. -
GET /verify?email=...&token=...
: valida token e marca como verificado.
-
Resultado: um fluxo completo, serverless, barato e pronto para plugar no front-end.
Observação importante: se sua conta SES estiver em sandbox, você precisa verificar o remetente e também os destinatários de teste, ou usar o mailbox simulator. Quando for para produção, peça saída de sandbox.
Arquitetura (visão rápida)
- Lambda (Node.js) com Function URL pública.
- SES com identidade de e-mail verificada (remetente).
-
DynamoDB com chave composta
pk
(e-mail) +sk
(token) eexpiresAt
como TTL. -
IAM Roles/Policies mínimos: DynamoDB R/W na tabela e SES
SendEmail
.
Pré-requisitos
- Node.js 18+ (usarei runtime nodejs20.x na Lambda).
- Terraform 1.6+.
- AWS CLI configurado.
- Uma caixa de e-mail para verificar como remetente no SES.
Estrutura de pastas
ses-dynamodb-lambda-verification/
infra/
main.tf
variables.tf
outputs.tf
modules/
ses/
main.tf
variables.tf
outputs.tf
dynamodb/
main.tf
variables.tf
outputs.tf
iam/
main.tf
variables.tf
outputs.tf
lambda/
main.tf
variables.tf
outputs.tf
app/
package.json
src/
core/env.js
domain/types.js
services/email/email-client.js
services/email/email-service.js
services/token/token-service.js
services/store/repository.js
usecases/send-verification.js
usecases/verify-token.js
handler.js
build.sh
Eu separei responsabilidades por módulos Terraform e camadas de código na Lambda, seguindo SOLID/DRY/YAGNI.
Infra com Terraform
1) Root: infra/main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.50"
}
}
}
provider "aws" {
region = var.aws_region
}
locals {
name_prefix = "${var.project_name}-${var.environment}"
}
module "ses" {
source = "./modules/ses"
sender_email = var.sender_email
name_prefix = local.name_prefix
}
module "dynamodb" {
source = "./modules/dynamodb"
table_name = "${local.name_prefix}-verifications"
}
module "iam" {
source = "./modules/iam"
name_prefix = local.name_prefix
dynamodb_table_arn = module.dynamodb.table_arn
ses_send_actions = ["ses:SendEmail", "ses:SendRawEmail"]
}
module "lambda" {
source = "./modules/lambda"
name_prefix = local.name_prefix
role_arn = module.iam.lambda_role_arn
artifact_path = var.lambda_artifact_path
env_map = {
TABLE_NAME = module.dynamodb.table_name
SES_SENDER = var.sender_email
VERIFY_BASE_URL = var.verify_base_url
TOKEN_TTL_SECONDS = tostring(var.token_ttl_seconds)
AWS_NODEJS_CONNECTION_REUSE_ENABLED = "1"
}
}
output "function_url" {
value = module.lambda.function_url
description = "URL pública da Lambda"
}
2) Root: infra/variables.tf
variable "aws_region" { type = string }
variable "project_name" { type = string }
variable "environment" { type = string }
variable "sender_email" { type = string }
variable "verify_base_url" { type = string, default = "" }
variable "lambda_artifact_path" { type = string }
variable "token_ttl_seconds" { type = number, default = 900 }
3) Root: infra/outputs.tf
output "table_name" { value = module.dynamodb.table_name }
output "function_url" { value = module.lambda.function_url }
Módulo SES: infra/modules/ses/main.tf
variable "sender_email" { type = string }
variable "name_prefix" { type = string }
resource "aws_sesv2_email_identity" "this" {
email_identity = var.sender_email
}
output "identity_arn" {
value = aws_sesv2_email_identity.this.arn
}
A identidade de e-mail do SES v2 é gerenciada pelo recurso
aws_sesv2_email_identity
.
Módulo DynamoDB: infra/modules/dynamodb/main.tf
variable "table_name" { type = string }
resource "aws_dynamodb_table" "this" {
name = var.table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "pk"
range_key = "sk"
attribute { name = "pk" type = "S" }
attribute { name = "sk" type = "S" }
ttl {
attribute_name = "expiresAt"
enabled = true
}
}
output "table_name" { value = aws_dynamodb_table.this.name }
output "table_arn" { value = aws_dynamodb_table.this.arn }
O bloco
ttl { attribute_name = "expiresAt" enabled = true }
habilita expiração automática de itens. A remoção é assíncrona e não consome WCU.
Módulo IAM: infra/modules/iam/main.tf
variable "name_prefix" { type = string }
variable "dynamodb_table_arn" { type = string }
variable "ses_send_actions" { type = list(string) }
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRole"]
principals { type = "Service" identifiers = ["lambda.amazonaws.com"] }
}
}
resource "aws_iam_role" "lambda" {
name = "${var.name_prefix}-lambda-role"
assume_role_policy = data.aws_iam_policy_document.assume.json
}
data "aws_iam_policy_document" "lambda_policy" {
statement {
actions = var.ses_send_actions
resources = ["*"]
}
statement {
actions = ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"]
resources = [var.dynamodb_table_arn]
}
statement {
actions = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["*"]
}
}
resource "aws_iam_policy" "lambda_inline" {
name = "${var.name_prefix}-lambda-policy"
policy = data.aws_iam_policy_document.lambda_policy.json
}
resource "aws_iam_role_policy_attachment" "attach" {
role = aws_iam_role.lambda.name
policy_arn = aws_iam_policy.lambda_inline.arn
}
output "lambda_role_arn" { value = aws_iam_role.lambda.arn }
Módulo Lambda: infra/modules/lambda/main.tf
variable "name_prefix" { type = string }
variable "role_arn" { type = string }
variable "artifact_path" { type = string }
variable "env_map" { type = map(string) }
resource "aws_lambda_function" "app" {
function_name = "${var.name_prefix}-verification"
role = var.role_arn
filename = var.artifact_path
handler = "handler.handler"
runtime = "nodejs20.x"
timeout = 10
memory_size = 256
environment {
variables = var.env_map
}
source_code_hash = filebase64sha256(var.artifact_path)
}
resource "aws_lambda_function_url" "public" {
function_name = aws_lambda_function.app.function_name
authorization_type = "NONE"
}
output "function_name" { value = aws_lambda_function.app.function_name }
output "function_url" { value = aws_lambda_function_url.public.function_url }
Function URL deixa a Lambda acessível por HTTP diretamente, com
authorization_type = "NONE"
para simplificar o teste.
Lambda (Node.js) — código de aplicação
app/package.json
{
"name": "email-verification",
"version": "1.0.0",
"type": "module",
"main": "src/handler.js",
"scripts": {
"zip": "rm -f function.zip && cd app && zip -r ../function.zip . -x \"*.zip\""
},
"dependencies": {
"@aws-sdk/client-sesv2": "^3.640.0",
"@aws-sdk/client-dynamodb": "^3.640.0",
"@aws-sdk/lib-dynamodb": "^3.640.0"
}
}
A Lambda usa SESv2 com
SendEmailCommand
do SDK v3.
app/src/core/env.js
export const env = {
tableName: process.env.TABLE_NAME,
sender: process.env.SES_SENDER,
verifyBaseUrl: process.env.VERIFY_BASE_URL || "",
tokenTtlSeconds: Number(process.env.TOKEN_TTL_SECONDS || "900")
};
app/src/domain/types.js
export const Status = Object.freeze({ Pending: "pending", Verified: "verified" });
app/src/services/token/token-service.js
import crypto from "crypto";
export function generateToken() {
return crypto.randomBytes(32).toString("base64url");
}
export function expirationEpoch(secondsFromNow) {
const now = Math.floor(Date.now() / 1000);
return now + secondsFromNow;
}
app/src/services/email/email-client.js
import { SESv2Client } from "@aws-sdk/client-sesv2";
export function makeSes(region) {
return new SESv2Client({ region });
}
app/src/services/email/email-service.js
import { SendEmailCommand } from "@aws-sdk/client-sesv2";
function subject() {
return "Confirme seu e-mail";
}
function htmlBody(link, token) {
return link
? `<p>Obrigado por se cadastrar.</p><p>Clique para verificar: <a href="${link}">${link}</a></p><p>Ou use este token: <b>${token}</b></p>`
: `<p>Obrigado por se cadastrar.</p><p>Use este token para verificar: <b>${token}</b></p>`;
}
function textBody(link, token) {
return link
? `Obrigado por se cadastrar.\nVerifique seu e-mail: ${link}\nToken: ${token}`
: `Obrigado por se cadastrar.\nToken de verificação: ${token}`;
}
export async function sendVerificationEmail({ ses, sender, to, link, token }) {
const input = {
FromEmailAddress: sender,
Destination: { ToAddresses: [to] },
Content: {
Simple: {
Subject: { Data: subject() },
Body: {
Html: { Data: htmlBody(link, token) },
Text: { Data: textBody(link, token) }
}
}
}
};
await ses.send(new SendEmailCommand(input));
}
app/src/services/store/repository.js
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, GetCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
export function makeRepo(tableName) {
const ddb = new DynamoDBClient({});
const doc = DynamoDBDocumentClient.from(ddb);
return {
async putPending({ email, token, expiresAt }) {
const item = { pk: email, sk: token, status: "pending", expiresAt };
await doc.send(new PutCommand({ TableName: tableName, Item: item, ConditionExpression: "attribute_not_exists(pk) AND attribute_not_exists(sk)" }));
return item;
},
async getOne({ email, token }) {
const res = await doc.send(new GetCommand({ TableName: tableName, Key: { pk: email, sk: token } }));
return res.Item || null;
},
async markVerified({ email, token }) {
await doc.send(new UpdateCommand({
TableName: tableName,
Key: { pk: email, sk: token },
UpdateExpression: "SET #s = :v",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: { ":v": "verified" }
}));
}
};
}
app/src/usecases/send-verification.js
import { generateToken, expirationEpoch } from "../services/token/token-service.js";
export async function sendVerification({ repo, emailService, email, baseUrl, ttlSeconds, sender }) {
const token = generateToken();
const expiresAt = expirationEpoch(ttlSeconds);
await repo.putPending({ email, token, expiresAt });
const link = baseUrl ? `${baseUrl.replace(/\/$/, "")}/verify?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}` : "";
await emailService.send({ to: email, token, link, sender });
return { email, expiresAt, linkPresent: Boolean(link) };
}
app/src/usecases/verify-token.js
export async function verifyToken({ repo, email, token }) {
const item = await repo.getOne({ email, token });
if (!item) return { ok: false, reason: "invalid" };
if (item.expiresAt && item.expiresAt < Math.floor(Date.now() / 1000)) return { ok: false, reason: "expired" };
await repo.markVerified({ email, token });
return { ok: true };
}
app/src/handler.js
import { env } from "./core/env.js";
import { makeRepo } from "./services/store/repository.js";
import { makeSes } from "./services/email/email-client.js";
import * as emailSvc from "./services/email/email-service.js";
import { sendVerification } from "./usecases/send-verification.js";
import { verifyToken } from "./usecases/verify-token.js";
const repo = makeRepo(env.tableName);
const ses = makeSes(process.env.AWS_REGION);
const emailService = {
send: ({ to, token, link, sender }) => emailSvc.sendVerificationEmail({ ses, sender: sender || env.sender, to, link, token })
};
function json(status, body, headers = {}) {
return { statusCode: status, headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) };
}
export async function handler(event) {
const rawPath = event.rawPath || "/";
const method = event.requestContext?.http?.method || "GET";
if (method === "POST" && rawPath.endsWith("/send")) {
const body = typeof event.body === "string" ? JSON.parse(event.body || "{}") : event.body || {};
if (!body?.email) return json(400, { error: "email_required" });
const result = await sendVerification({
repo,
emailService,
email: body.email,
baseUrl: env.verifyBaseUrl,
ttlSeconds: env.tokenTtlSeconds,
sender: env.sender
});
return json(202, { email: result.email, expiresAt: result.expiresAt, linkIncluded: result.linkPresent });
}
if (method === "GET" && rawPath.endsWith("/verify")) {
const qs = event.rawQueryString || "";
const params = Object.fromEntries(new URLSearchParams(qs).entries());
const email = params.email || "";
const token = params.token || "";
if (!email || !token) return json(400, { error: "email_and_token_required" });
const res = await verifyToken({ repo, email, token });
if (!res.ok) return json(400, { verified: false, reason: res.reason });
return json(200, { verified: true });
}
return json(404, { error: "not_found" });
}
Build do artefato da Lambda
app/build.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
npm ci
cd ..
rm -f function.zip
cd app
zip -r ../function.zip . -x "*.zip"
Esse zip contém src/
e node_modules
. O handler é handler.handler
como configurado.
Deploy: passo a passo
- Ajuste variáveis:
Crie um arquivo infra/terraform.tfvars
:
aws_region = "us-east-1"
project_name = "email-verify"
environment = "dev"
sender_email = "seu-remetente@dominio.com"
lambda_artifact_path = "../function.zip"
token_ttl_seconds = 900
verify_base_url = "" # deixe vazio por agora, ou coloque a URL que fará o GET /verify
Verifique/prepare o remetente no SES (console) ou com Terraform do módulo
ses
. Se a conta estiver em sandbox, verifique também o destinatário de teste.Gere o pacote da Lambda:
bash app/build.sh
- Aplique Terraform:
cd infra
terraform init
terraform apply -auto-approve
- Copie a Function URL de saída.
Se quiser que o e-mail traga o link direto de verificação apontando para a própria Lambda, defina verify_base_url
igual à Function URL e rode:
terraform apply -var="verify_base_url=$(terraform output -raw function_url)" -auto-approve
Como testar (curl, Postman e AWS CLI)
1) Disparar e-mail de verificação
FUNCTION_URL=$(terraform -chdir=infra output -raw function_url)
curl -s -X POST "$FUNCTION_URL/send" \
-H "content-type: application/json" \
-d '{"email":"destinatario@exemplo.com"}' | jq
Resposta esperada:
{
"email": "destinatario@exemplo.com",
"expiresAt": 1724099999,
"linkIncluded": true
}
Se verify_base_url
estiver vazio, linkIncluded
será false
e o corpo do e-mail trará apenas o token.
O envio usa a operação SESv2 SendEmail do SDK v3.
2) Validar o token
Pegue o link no e-mail (se configurado) ou recupere o token no próprio e-mail. Então:
curl -s "$FUNCTION_URL/verify?email=destinatario@exemplo.com&token=SEU_TOKEN" | jq
Resposta esperada em sucesso:
{ "verified": true }
Falhas retornam HTTP 400 com reason
igual a invalid
ou expired
.
3) Conferir no DynamoDB com AWS CLI
aws dynamodb get-item \
--table-name $(terraform -chdir=infra output -raw table_name) \
--key '{"pk":{"S":"destinatario@exemplo.com"}, "sk":{"S":"SEU_TOKEN"}}'
Você vai ver status
mudando para verified
. Se estiver aguardando expiração, lembre que o TTL é assíncrono; a remoção pode levar algum tempo.
Custos, limites e produção
- DynamoDB com PAY_PER_REQUEST e TTL tem custo muito baixo no cenário de verificação.
- Lambda é cobrada por invocação e tempo de execução, tipicamente centavos.
- SES em sandbox limita destinatários e throughput; para produção, solicite saída de sandbox.
- Para domínios próprios, prefira identidade de domínio e configure DKIM/SPF; aqui usei identidade por e-mail para simplificar o início.
Próximos passos
- Substituir a Function URL por API Gateway com authorizers.
- Adicionar template HTML rico e Configuration Set do SES (monitoramento, IP pool gerenciado).
- Colocar a verificação atrás de um domínio customizado e TLS, com CloudFront + Function URL ou API Gateway.
Com isso, você tem um pipeline completo de verificação de e-mail, IaC de ponta a ponta e uma Lambda enxuta pronta para conectar ao seu front-end.
💡Curtiu?
Se quiser trocar ideia sobre IA, cloud e arquitetura, me segue nas redes:
Publico conteúdos técnicos direto do campo de batalha. E quando descubro uma ferramenta que economiza tempo e resolve bem, como essa, você fica sabendo também.
Referências
Daniel, G. (2025, August 9). AWS SES with a NestJS Backend to Send Email Verifications. DEV Community. https://dev.to/aws-builders/aws-ses-with-a-nestjs-backend-to-send-email-verifications-2l9h (DEV Community)
Amazon Web Services. (n.d.). Request production access (Moving out of the Amazon SES sandbox). In Amazon Simple Email Service Developer Guide. https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html (AWS Documentation)
Amazon Web Services. (n.d.). Verified identities in Amazon SES. In Amazon Simple Email Service Developer Guide. https://docs.aws.amazon.com/ses/latest/dg/verify-addresses-and-domains.html (AWS Documentation)
Amazon Web Services. (n.d.). Using time to live (TTL) in DynamoDB. In Amazon DynamoDB Developer Guide. https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html (AWS Documentation)
HashiCorp. (n.d.). aws_lambda_function_url (Resource). Terraform Registry. https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function_url.html (Terraform Registry)
Amazon Web Services. (n.d.). Creating and managing Lambda function URLs. In AWS Lambda Developer Guide. https://docs.aws.amazon.com/lambda/latest/dg/urls-configuration.html (AWS Documentation)
Amazon Web Services. (n.d.). SendEmailCommand (SESv2). In AWS SDK for JavaScript v3 API Reference. https://docs.aws.amazon.com/goto/SdkForJavaScriptV3/sesv2-2019-09-27/SendEmail (AWS Documentation)
Amazon Web Services. (n.d.). SESv2Client. In AWS SDK for JavaScript v3. https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/sesv2 (AWS Documentation)
Top comments (0)