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



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, montamos Destination, Content.Simple e FromEmailAddress 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