Back to Blog

AWS SES Transactional Email: Setup, Templates, DKIM/SPF, and Bounce Handling

Set up AWS SES for transactional email. Covers Terraform configuration, DKIM and SPF DNS records, email templates, bounce and complaint handling via SNS, suppression list management, and Nodemailer integration.

Viprasol Tech Team
March 28, 2027
13 min read

AWS SES is the cheapest transactional email service at scale — $0.10 per 1,000 emails with no monthly minimum. But getting it right requires more than just calling sendEmail: you need DKIM and SPF for deliverability, SNS for bounce and complaint feedback loops, suppression list management to protect your sender reputation, and rate limit handling in your application code.

This guide covers the complete production SES setup.

Terraform Infrastructure

# terraform/ses.tf

# Domain identity — verifies you own the domain
resource "aws_ses_domain_identity" "app" {
  domain = var.email_domain  # "yourapp.com"
}

# DKIM — generates 3 CNAME records for email signing
resource "aws_ses_domain_dkim" "app" {
  domain = aws_ses_domain_identity.app.domain
}

# Route53: DKIM CNAME records (add to your DNS)
resource "aws_route53_record" "ses_dkim" {
  count   = 3
  zone_id = var.route53_zone_id
  name    = "${aws_ses_domain_dkim.app.dkim_tokens[count.index]}._domainkey"
  type    = "CNAME"
  ttl     = 1800
  records = ["${aws_ses_domain_dkim.app.dkim_tokens[count.index]}.dkim.amazonses.com"]
}

# Route53: SPF TXT record
resource "aws_route53_record" "spf" {
  zone_id = var.route53_zone_id
  name    = var.email_domain
  type    = "TXT"
  ttl     = 3600
  records = ["v=spf1 include:amazonses.com ~all"]
}

# Route53: DMARC policy
resource "aws_route53_record" "dmarc" {
  zone_id = var.route53_zone_id
  name    = "_dmarc.${var.email_domain}"
  type    = "TXT"
  ttl     = 3600
  records = ["v=DMARC1; p=quarantine; rua=mailto:dmarc@${var.email_domain}; pct=100"]
}

# Route53: MX record for email receiving (required for MAIL FROM domain)
resource "aws_ses_domain_mail_from" "app" {
  domain           = aws_ses_domain_identity.app.domain
  mail_from_domain = "mail.${var.email_domain}"
}

resource "aws_route53_record" "ses_mail_from_mx" {
  zone_id = var.route53_zone_id
  name    = aws_ses_domain_mail_from.app.mail_from_domain
  type    = "MX"
  ttl     = 600
  records = ["10 feedback-smtp.${var.aws_region}.amazonses.com"]
}

resource "aws_route53_record" "ses_mail_from_spf" {
  zone_id = var.route53_zone_id
  name    = aws_ses_domain_mail_from.app.mail_from_domain
  type    = "TXT"
  ttl     = 600
  records = ["v=spf1 include:amazonses.com ~all"]
}

# SNS Topic for bounce/complaint notifications
resource "aws_sns_topic" "ses_notifications" {
  name = "${var.app_name}-ses-notifications"
}

# SES notification configuration
resource "aws_ses_identity_notification_topic" "bounces" {
  topic_arn                = aws_sns_topic.ses_notifications.arn
  notification_type        = "Bounce"
  identity                 = aws_ses_domain_identity.app.domain
  include_original_headers = false
}

resource "aws_ses_identity_notification_topic" "complaints" {
  topic_arn                = aws_sns_topic.ses_notifications.arn
  notification_type        = "Complaint"
  identity                 = aws_ses_domain_identity.app.domain
  include_original_headers = false
}

# SNS → SQS for reliable processing
resource "aws_sqs_queue" "ses_events" {
  name                       = "${var.app_name}-ses-events"
  visibility_timeout_seconds = 60
  message_retention_seconds  = 86400  # 1 day
}

resource "aws_sns_topic_subscription" "ses_to_sqs" {
  topic_arn = aws_sns_topic.ses_notifications.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.ses_events.arn
}

resource "aws_sqs_queue_policy" "ses_events" {
  queue_url = aws_sqs_queue.ses_events.url
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "sns.amazonaws.com" }
      Action    = "sqs:SendMessage"
      Resource  = aws_sqs_queue.ses_events.arn
      Condition = { ArnEquals = { "aws:SourceArn" = aws_sns_topic.ses_notifications.arn } }
    }]
  })
}

# IAM: SES send permissions for your app
resource "aws_iam_policy" "ses_send" {
  name = "${var.app_name}-ses-send"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["ses:SendEmail", "ses:SendRawEmail", "ses:SendTemplatedEmail"]
        Resource = "*"
        Condition = {
          StringEquals = { "ses:FromAddress" = ["noreply@${var.email_domain}", "support@${var.email_domain}"] }
        }
      },
      {
        Effect   = "Allow"
        Action   = ["ses:GetSuppressedDestination", "ses:PutSuppressedDestination", "ses:DeleteSuppressedDestination"]
        Resource = "*"
      }
    ]
  })
}

output "ses_events_queue_url" { value = aws_sqs_queue.ses_events.url }

Email Client with Rate Limiting

SES has account-level rate limits (default 14 emails/second; can be increased). Always implement a rate limiter:

// lib/email/ses-client.ts
import {
  SESv2Client,
  SendEmailCommand,
  type SendEmailCommandInput,
} from "@aws-sdk/client-sesv2";
import Bottleneck from "bottleneck";

// Rate limiter: 14 emails/second default SES limit
// Use `reservoir` to handle burst + sustained rate
const limiter = new Bottleneck({
  minTime: 72,        // 1000ms / 14 = ~72ms per email
  maxConcurrent: 5,
  reservoir: 14,      // Burst: 14 at once
  reservoirRefreshAmount: 14,
  reservoirRefreshInterval: 1000,
});

const sesClient = new SESv2Client({
  region: process.env.AWS_REGION!,
  // In production, uses IAM role; locally use credentials
  ...(process.env.AWS_ACCESS_KEY_ID
    ? {
        credentials: {
          accessKeyId: process.env.AWS_ACCESS_KEY_ID,
          secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
        },
      }
    : {}),
});

export interface SendEmailParams {
  to: string | string[];
  from?: string;
  subject: string;
  html: string;
  text?: string;
  replyTo?: string;
  tags?: Record<string, string>;
}

export async function sendEmail(params: SendEmailParams): Promise<string> {
  const toAddresses = Array.isArray(params.to) ? params.to : [params.to];

  const input: SendEmailCommandInput = {
    FromEmailAddress: params.from ?? `noreply@${process.env.EMAIL_DOMAIN}`,
    Destination: { ToAddresses: toAddresses },
    ReplyToAddresses: params.replyTo ? [params.replyTo] : undefined,
    Content: {
      Simple: {
        Subject: { Data: params.subject, Charset: "UTF-8" },
        Body: {
          Html: { Data: params.html, Charset: "UTF-8" },
          Text: { Data: params.text ?? stripHtml(params.html), Charset: "UTF-8" },
        },
      },
    },
    // Message tags for filtering in SES dashboard
    EmailTags: params.tags
      ? Object.entries(params.tags).map(([Name, Value]) => ({ Name, Value }))
      : undefined,
  };

  const result = await limiter.schedule(() =>
    sesClient.send(new SendEmailCommand(input))
  );

  return result.MessageId ?? "";
}

function stripHtml(html: string): string {
  return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
}

☁️ Is Your Cloud Costing Too Much?

Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.

  • AWS, GCP, Azure certified engineers
  • Infrastructure as Code (Terraform, CDK)
  • Docker, Kubernetes, GitHub Actions CI/CD
  • Typical audit recovers $500–$3,000/month in savings

Email Templates

// lib/email/templates/welcome.tsx
import * as React from "react";
import { Html, Head, Body, Container, Heading, Text, Button, Hr, Img } from "@react-email/components";

interface WelcomeEmailProps {
  userName: string;
  confirmUrl: string;
}

export function WelcomeEmail({ userName, confirmUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Body style={{ backgroundColor: "#f9fafb", fontFamily: "system-ui, sans-serif" }}>
        <Container style={{ maxWidth: "560px", margin: "40px auto", backgroundColor: "#ffffff", borderRadius: "8px", padding: "32px", border: "1px solid #e5e7eb" }}>
          <Img src="https://yourapp.com/logo.png" alt="Acme" width={80} height={24} />
          <Heading style={{ fontSize: "20px", fontWeight: "700", color: "#111827", margin: "24px 0 8px" }}>
            Welcome, {userName}!
          </Heading>
          <Text style={{ color: "#374151", lineHeight: "1.6", margin: "0 0 24px" }}>
            Your account is ready. Confirm your email address to get started.
          </Text>
          <Button
            href={confirmUrl}
            style={{ backgroundColor: "#2563eb", color: "#ffffff", padding: "12px 24px", borderRadius: "6px", fontWeight: "600", textDecoration: "none", display: "inline-block" }}
          >
            Confirm email address
          </Button>
          <Hr style={{ borderColor: "#e5e7eb", margin: "32px 0" }} />
          <Text style={{ fontSize: "12px", color: "#9ca3af" }}>
            If you didn't create an account, you can safely ignore this email.
            This link expires in 24 hours.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

// Render to HTML string
import { render } from "@react-email/render";

export async function renderWelcomeEmail(props: WelcomeEmailProps): Promise<string> {
  return render(<WelcomeEmail {...props} />);
}

Bounce and Complaint Handler

This is critical for deliverability. Unprocessed bounces destroy your sender reputation:

// workers/ses-event-processor.ts
import {
  SQSClient,
  ReceiveMessageCommand,
  DeleteMessageCommand,
} from "@aws-sdk/client-sqs";
import { prisma } from "@/lib/prisma";

const sqs = new SQSClient({ region: process.env.AWS_REGION! });
const QUEUE_URL = process.env.SES_EVENTS_QUEUE_URL!;

interface SESBounceNotification {
  notificationType: "Bounce";
  bounce: {
    bounceType: "Permanent" | "Transient" | "Undetermined";
    bounceSubType: string;
    bouncedRecipients: Array<{ emailAddress: string; diagnosticCode?: string }>;
    timestamp: string;
  };
  mail: { messageId: string; source: string };
}

interface SESComplaintNotification {
  notificationType: "Complaint";
  complaint: {
    complainedRecipients: Array<{ emailAddress: string }>;
    complaintFeedbackType?: string;
    timestamp: string;
  };
  mail: { messageId: string; source: string };
}

type SESNotification = SESBounceNotification | SESComplaintNotification;

export async function processSESEvents(): Promise<void> {
  while (true) {
    const messages = await sqs.send(
      new ReceiveMessageCommand({
        QueueUrl: QUEUE_URL,
        MaxNumberOfMessages: 10,
        WaitTimeSeconds: 20, // Long polling
      })
    );

    if (!messages.Messages?.length) continue;

    for (const message of messages.Messages) {
      try {
        // SNS wraps the SES notification in an outer envelope
        const snsEnvelope = JSON.parse(message.Body ?? "{}");
        const sesNotification: SESNotification = JSON.parse(snsEnvelope.Message);

        await handleSESNotification(sesNotification);

        await sqs.send(
          new DeleteMessageCommand({
            QueueUrl: QUEUE_URL,
            ReceiptHandle: message.ReceiptHandle!,
          })
        );
      } catch (err) {
        console.error("Failed to process SES event:", err);
        // Message returns to queue after visibility timeout
      }
    }
  }
}

async function handleSESNotification(notification: SESNotification): Promise<void> {
  if (notification.notificationType === "Bounce") {
    await handleBounce(notification);
  } else if (notification.notificationType === "Complaint") {
    await handleComplaint(notification);
  }
}

async function handleBounce(notification: SESBounceNotification): Promise<void> {
  const { bounce } = notification;

  for (const recipient of bounce.bouncedRecipients) {
    const email = recipient.emailAddress.toLowerCase();

    if (bounce.bounceType === "Permanent") {
      // Hard bounce: add to suppression list immediately
      await prisma.emailSuppression.upsert({
        where: { email },
        create: {
          email,
          reason: "hard_bounce",
          bounceType: bounce.bounceSubType,
          suppressedAt: new Date(bounce.timestamp),
        },
        update: {
          reason: "hard_bounce",
          suppressedAt: new Date(bounce.timestamp),
        },
      });

      console.log(`Hard bounce suppressed: ${email}`);
    } else {
      // Soft bounce: increment counter; suppress after 3
      const result = await prisma.emailBounceTracking.upsert({
        where: { email },
        create: { email, softBounceCount: 1, lastBounceAt: new Date() },
        update: {
          softBounceCount: { increment: 1 },
          lastBounceAt: new Date(),
        },
      });

      if (result.softBounceCount >= 3) {
        await prisma.emailSuppression.upsert({
          where: { email },
          create: { email, reason: "soft_bounce_repeated", suppressedAt: new Date() },
          update: { reason: "soft_bounce_repeated", suppressedAt: new Date() },
        });
      }
    }
  }
}

async function handleComplaint(notification: SESComplaintNotification): Promise<void> {
  for (const recipient of notification.complaint.complainedRecipients) {
    const email = recipient.emailAddress.toLowerCase();

    // Suppress immediately on complaint — no second chances
    await prisma.emailSuppression.upsert({
      where: { email },
      create: {
        email,
        reason: "complaint",
        suppressedAt: new Date(notification.complaint.timestamp),
      },
      update: {
        reason: "complaint",
        suppressedAt: new Date(),
      },
    });

    console.log(`Complaint suppressed: ${email}`);
  }
}

⚙️ DevOps Done Right — Zero Downtime, Full Automation

Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.

  • Staging + production environments with feature flags
  • Automated security scanning in the pipeline
  • Uptime monitoring + alerting + runbook automation
  • On-call support handover docs included

Pre-Send Suppression Check

// lib/email/send-safe.ts
import { prisma } from "@/lib/prisma";
import { sendEmail, type SendEmailParams } from "./ses-client";

export async function sendEmailSafe(
  params: SendEmailParams & { userId?: string }
): Promise<{ sent: boolean; reason?: string }> {
  const toAddresses = Array.isArray(params.to) ? params.to : [params.to];

  // Check suppression list before sending
  const suppressions = await prisma.emailSuppression.findMany({
    where: { email: { in: toAddresses.map((e) => e.toLowerCase()) } },
  });

  if (suppressions.length > 0) {
    const suppressed = suppressions.map((s) => s.email).join(", ");
    console.warn(`Skipping suppressed addresses: ${suppressed}`);
    return { sent: false, reason: `Suppressed: ${suppressed}` };
  }

  await sendEmail(params);
  return { sent: true };
}

Suppression Table Schema

CREATE TABLE email_suppression (
  email         TEXT PRIMARY KEY,
  reason        TEXT NOT NULL,   -- 'hard_bounce', 'soft_bounce_repeated', 'complaint', 'manual'
  bounce_type   TEXT,
  suppressed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE email_bounce_tracking (
  email             TEXT PRIMARY KEY,
  soft_bounce_count INTEGER NOT NULL DEFAULT 0,
  last_bounce_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_suppression_email ON email_suppression(email);

Cost Estimates

VolumeSES CostComparison
10K emails/month$1.00Resend: $20, Mailgun: $15
100K emails/month$10.00Resend: $89, Mailgun: $80
1M emails/month$100.00Resend: $390, Mailgun: $700
EC2-originated emails$0 (free)All providers charge

SES from EC2/Lambda is free for the first 62K emails per month when sent from an EC2 instance.

Dev/setup costs:

  • Basic SES setup + Terraform: 1–2 days, $300–600
  • Full system (bounce handling + suppression + templates): 1 week, $1,500–3,000
  • High-volume setup with warm-up strategy: 2–3 weeks, $4,000–8,000

See Also


Working With Viprasol

SES is powerful but unforgiving — a bad bounce rate tanks your sender reputation permanently, and there's no easy recovery. Our team sets up SES correctly from the start: DKIM, SPF, DMARC, suppression list management, bounce/complaint webhooks via SNS+SQS, and rate-limited sending that won't blow your account's sending limits.

What we deliver:

  • Terraform: SES domain identity, DKIM, SPF, DMARC, mail-from domain, SNS+SQS notification pipeline
  • Nodemailer/SESv2 client with Bottleneck rate limiting
  • React Email templates with server-side HTML rendering
  • SQS consumer for bounce/complaint processing
  • Pre-send suppression check to protect deliverability

Talk to our team about your transactional email setup →

Or explore our cloud infrastructure services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

Making sense of your data at scale?

Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.