All Articles
Cloud & DevOps
12 min read

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.

Building Scalable Email Infrastructure with AWS SES
DP

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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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

Found this helpful? Share it with others

DP

About the Author

Dibyank Padhy is an Engineering Manager & Full Stack Developer with 7+ years of experience building scalable software solutions. Passionate about cloud architecture, team leadership, and AI integration.