DEV Community

Cover image for E-mails de verificação com AWS SES + Lambda (Node.js) e Terraform: do zero ao envio
Cláudio Filipe Lima Rapôso
Cláudio Filipe Lima Rapôso

Posted on • Edited on

E-mails de verificação com AWS SES + Lambda (Node.js) e Terraform: do zero ao envio

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

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) e expiresAt 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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

3) Root: infra/outputs.tf

output "table_name" { value = module.dynamodb.table_name }
output "function_url" { value = module.lambda.function_url }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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")
};
Enter fullscreen mode Exit fullscreen mode

app/src/domain/types.js

export const Status = Object.freeze({ Pending: "pending", Verified: "verified" });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

app/src/services/email/email-client.js

import { SESv2Client } from "@aws-sdk/client-sesv2";

export function makeSes(region) {
  return new SESv2Client({ region });
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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" }
      }));
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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) };
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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" });
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Esse zip contém src/ e node_modules. O handler é handler.handler como configurado.


Deploy: passo a passo

  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. 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.

  2. Gere o pacote da Lambda:

bash app/build.sh
Enter fullscreen mode Exit fullscreen mode
  1. Aplique Terraform:
cd infra
terraform init
terraform apply -auto-approve
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Resposta esperada:

{
  "email": "destinatario@exemplo.com",
  "expiresAt": 1724099999,
  "linkIncluded": true
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Resposta esperada em sucesso:

{ "verified": true }
Enter fullscreen mode Exit fullscreen mode

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"}}'
Enter fullscreen mode Exit fullscreen mode

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)