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.
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
| Volume | SES Cost | Comparison |
|---|---|---|
| 10K emails/month | $1.00 | Resend: $20, Mailgun: $15 |
| 100K emails/month | $10.00 | Resend: $89, Mailgun: $80 |
| 1M emails/month | $100.00 | Resend: $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
- SaaS Email Sequences and Drip Campaigns
- SaaS Onboarding Flow and Activation
- AWS Secrets Manager for Credentials
- AWS SQS and SNS Messaging Patterns
- SaaS Changelog and Subscriber Notifications
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.
About the Author
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.
Need DevOps & Cloud Expertise?
Scale your infrastructure with confidence. AWS, GCP, Azure certified team.
Free consultation • No commitment • Response within 24 hours
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.