Building Scalable Email Infrastructure with AWS SES
Learn how to build a production-ready email sending system using AWS Simple Email Service with bounce handling, deliverability optimization, and monitoring.
Dibyank Padhy
Engineering Manager & Full Stack Developer
Building Scalable Email Infrastructure with AWS SES
Amazon Simple Email Service (SES) is a cost-effective, flexible, and scalable email service that enables developers to send mail from within any application. In this guide, we'll build a production-ready email infrastructure that handles transactional emails, marketing campaigns, and proper bounce management.
Prerequisites
Before we begin, ensure you have:
- AWS Account with SES access
- Node.js 18+ installed
- Verified domain in SES
- IAM credentials with SES permissions
Setting Up the SES Client
First, let's create a robust SES client with retry logic and proper error handling:
import { SESClient, SendEmailCommand, SendBulkTemplatedEmailCommand } from '@aws-sdk/client-ses';
const sesClient = new SESClient({
region: process.env.AWS_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
maxAttempts: 3,
});
interface EmailOptions {
to: string | string[];
subject: string;
html: string;
text?: string;
replyTo?: string;
configurationSet?: string;
}
export async function sendEmail(options: EmailOptions): Promise<string> {
const { to, subject, html, text, replyTo, configurationSet } = options;
const command = new SendEmailCommand({
Source: process.env.SES_FROM_EMAIL,
Destination: {
ToAddresses: Array.isArray(to) ? to : [to],
},
Message: {
Subject: { Data: subject, Charset: 'UTF-8' },
Body: {
Html: { Data: html, Charset: 'UTF-8' },
Text: { Data: text || stripHtml(html), Charset: 'UTF-8' },
},
},
ReplyToAddresses: replyTo ? [replyTo] : undefined,
ConfigurationSetName: configurationSet,
});
const response = await sesClient.send(command);
return response.MessageId;
}Handling Bounces and Complaints
AWS SES sends bounce and complaint notifications via SNS. Setting up proper handling is crucial for maintaining sender reputation:
import { SNSEvent, SNSHandler } from 'aws-lambda';
interface SESNotification {
notificationType: 'Bounce' | 'Complaint' | 'Delivery';
bounce?: {
bounceType: 'Permanent' | 'Transient';
bounceSubType: string;
bouncedRecipients: Array<{ emailAddress: string }>;
};
complaint?: {
complainedRecipients: Array<{ emailAddress: string }>;
complaintFeedbackType: string;
};
mail: {
messageId: string;
destination: string[];
};
}
export const handler: SNSHandler = async (event: SNSEvent) => {
for (const record of event.Records) {
const notification: SESNotification = JSON.parse(record.Sns.Message);
switch (notification.notificationType) {
case 'Bounce':
await handleBounce(notification);
break;
case 'Complaint':
await handleComplaint(notification);
break;
case 'Delivery':
await handleDelivery(notification);
break;
}
}
};
async function handleBounce(notification: SESNotification) {
const { bounce } = notification;
if (!bounce) return;
for (const recipient of bounce.bouncedRecipients) {
if (bounce.bounceType === 'Permanent') {
// Add to suppression list - never send to this address again
await addToSuppressionList(recipient.emailAddress, 'hard_bounce');
console.log(`Hard bounce: ${recipient.emailAddress}`);
} else {
// Soft bounce - retry later but track attempts
await incrementBounceCount(recipient.emailAddress);
console.log(`Soft bounce: ${recipient.emailAddress}`);
}
}
}
async function handleComplaint(notification: SESNotification) {
const { complaint } = notification;
if (!complaint) return;
for (const recipient of complaint.complainedRecipients) {
// Immediately unsubscribe and add to suppression list
await addToSuppressionList(recipient.emailAddress, 'complaint');
await unsubscribeUser(recipient.emailAddress);
console.log(`Complaint received from: ${recipient.emailAddress}`);
}
}Email Templates with Dynamic Content
For transactional emails, using templates improves consistency and makes updates easier:
import { CreateTemplateCommand, SendTemplatedEmailCommand } from '@aws-sdk/client-ses';
// Create a reusable template
async function createEmailTemplate(templateName: string) {
const command = new CreateTemplateCommand({
Template: {
TemplateName: templateName,
SubjectPart: 'Welcome to {{company}}, {{name}}!',
HtmlPart: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
.container { max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; }
.header { background: #6366f1; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; background: #f9fafb; }
.button {
display: inline-block;
padding: 12px 24px;
background: #6366f1;
color: white;
text-decoration: none;
border-radius: 6px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome, {{name}}!</h1>
</div>
<div class="content">
<p>Thanks for joining {{company}}. We're excited to have you!</p>
<p>Your account has been created with the email: <strong>{{email}}</strong></p>
<p style="text-align: center; margin-top: 30px;">
<a href="{{dashboardUrl}}" class="button">Go to Dashboard</a>
</p>
</div>
</div>
</body>
</html>
`,
TextPart: 'Welcome {{name}}! Thanks for joining {{company}}. Visit your dashboard at {{dashboardUrl}}',
},
});
await sesClient.send(command);
}
// Send templated email
async function sendWelcomeEmail(user: { name: string; email: string }) {
const command = new SendTemplatedEmailCommand({
Source: process.env.SES_FROM_EMAIL,
Destination: { ToAddresses: [user.email] },
Template: 'welcome-template',
TemplateData: JSON.stringify({
name: user.name,
email: user.email,
company: 'Acme Inc',
dashboardUrl: 'https://app.example.com/dashboard',
}),
});
return sesClient.send(command);
}Rate Limiting and Queue Management
SES has sending limits. Here's how to implement proper rate limiting with SQS:
import { SQSClient, SendMessageCommand, ReceiveMessageCommand, DeleteMessageCommand } from '@aws-sdk/client-sqs';
const sqsClient = new SQSClient({ region: process.env.AWS_REGION });
const EMAIL_QUEUE_URL = process.env.EMAIL_QUEUE_URL;
// Rate limiter using token bucket algorithm
class RateLimiter {
private tokens: number;
private lastRefill: number;
private readonly maxTokens: number;
private readonly refillRate: number; // tokens per second
constructor(maxTokens: number, refillRate: number) {
this.maxTokens = maxTokens;
this.tokens = maxTokens;
this.refillRate = refillRate;
this.lastRefill = Date.now();
}
async acquire(): Promise<boolean> {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
private refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
}
// SES default is 14 emails/second for production accounts
const rateLimiter = new RateLimiter(14, 14);
export async function queueEmail(emailData: EmailOptions): Promise<void> {
const command = new SendMessageCommand({
QueueUrl: EMAIL_QUEUE_URL,
MessageBody: JSON.stringify(emailData),
MessageAttributes: {
Priority: {
DataType: 'String',
StringValue: emailData.priority || 'normal',
},
},
});
await sqsClient.send(command);
}
// Lambda handler for processing email queue
export async function processEmailQueue(): Promise<void> {
const receiveCommand = new ReceiveMessageCommand({
QueueUrl: EMAIL_QUEUE_URL,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
});
const { Messages } = await sqsClient.send(receiveCommand);
if (!Messages) return;
for (const message of Messages) {
if (await rateLimiter.acquire()) {
const emailData: EmailOptions = JSON.parse(message.Body!);
try {
await sendEmail(emailData);
// Delete from queue after successful send
await sqsClient.send(new DeleteMessageCommand({
QueueUrl: EMAIL_QUEUE_URL,
ReceiptHandle: message.ReceiptHandle,
}));
} catch (error) {
console.error('Failed to send email:', error);
// Message will return to queue after visibility timeout
}
} else {
// Rate limited - wait and retry
await new Promise(resolve => setTimeout(resolve, 100));
}
}
}Monitoring and Metrics
Set up CloudWatch metrics to monitor your email infrastructure:
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
const cloudWatchClient = new CloudWatchClient({ region: process.env.AWS_REGION });
async function recordEmailMetric(
metricName: string,
value: number,
dimensions: Record<string, string> = {}
) {
const command = new PutMetricDataCommand({
Namespace: 'EmailService',
MetricData: [{
MetricName: metricName,
Value: value,
Unit: 'Count',
Timestamp: new Date(),
Dimensions: Object.entries(dimensions).map(([Name, Value]) => ({ Name, Value })),
}],
});
await cloudWatchClient.send(command);
}
// Usage in email sending
export async function sendEmailWithMetrics(options: EmailOptions): Promise<string> {
const startTime = Date.now();
try {
const messageId = await sendEmail(options);
await recordEmailMetric('EmailsSent', 1, { Status: 'Success' });
await recordEmailMetric('EmailLatency', Date.now() - startTime, { Unit: 'Milliseconds' });
return messageId;
} catch (error) {
await recordEmailMetric('EmailsSent', 1, { Status: 'Failed' });
throw error;
}
}Conclusion
Building a scalable email infrastructure with AWS SES requires careful attention to bounce handling, rate limiting, and monitoring. The patterns shown here will help you build a robust system that maintains high deliverability while scaling to millions of emails.
Key takeaways:
- Always handle bounces and complaints to protect sender reputation
- Use SQS for queue management and rate limiting
- Implement proper monitoring with CloudWatch
- Use templates for consistent, maintainable emails