Testing SES Emails in Local Development

Testing email functionality during local development has always been challenging. Send test emails to real addresses and risk spamming. Use a third-party service and deal with API keys and quotas. Or worse - skip email testing entirely and hope everything works in production.

LocalStack’s SES implementation solves this by intercepting email sends and storing them locally. But viewing those emails efficiently is where the real workflow improvement happens. This post explains how to use a lightweight bridge that connects LocalStack’s SES API to Mailpit’s testing interface, giving both programmatic access and a modern UI for email inspection.

The Problem with Testing Emails Locally

When building applications that send emails through AWS SES, developers face a dilemma. Testing in production isn’t an option. AWS SES sandbox mode requires verified email addresses. Setting up a full SMTP server locally is overkill for most development workflows.

LocalStack provides SES emulation, capturing all emails sent through the service. The emails are stored in memory and accessible via an API endpoint. The challenge becomes: how do we efficiently view and debug these emails during development?

Available Solutions

Two main approaches exist for viewing LocalStack SES emails:

1. LocalStack Web UI (Pro)

LocalStack Pro includes a web interface that displays captured emails. It provides a full-featured dashboard with email listing, content preview, and detailed metadata.

Pros:

Cons:

2. Bridge to Mailpit

The localstack-aws-ses-email-viewer provides a lightweight Node.js app that connects directly to LocalStack’s SES API endpoint and optionally forwards emails to Mailpit via SMTP.

Architecture:

LocalStack SES → Custom Viewer → Mailpit (optional)
                      ↓
                  Simple Web UI

Pros:

Cons:

How LocalStack SES Works

LocalStack intercepts AWS SDK calls to SES and stores emails in memory. All emails sent through the service are accessible via:

curl http://localhost:4566/_aws/ses

This returns a JSON response:

{
  "messages": [
    {
      "Timestamp": 1700000000000,
      "RawData": "From: sender@example.com\nTo: recipient@example.com\n...",
      "Subject": "Test Email",
      "Destination": {
        "ToAddresses": ["recipient@example.com"],
        "CcAddresses": [],
        "BccAddresses": []
      },
      "Body": {
        "html_part": "<html>...</html>",
        "text_part": "Plain text version"
      }
    }
  ]
}

LocalStack returns two email formats:

The viewer handles both formats seamlessly.

Setting Up the Viewer

Option 1: Basic Setup (Viewer Only)

Add the viewer to your docker-compose.yml:

version: '3.8'

services:
  localstack:
    image: localstack/localstack
    ports:
      - "4566:4566"
    environment:
      - SERVICES=ses
      - DEBUG=1

  ses-viewer:
    build:
      context: https://github.com/veertech/localstack-aws-ses-email-viewer.git#main
    ports:
      - "3005:3005"
    environment:
      - LOCALSTACK_HOST=http://localstack:4566
    depends_on:
      - localstack

Start the services:

docker-compose up

Open http://localhost:3005 to view captured emails.

For the best experience, enable SMTP forwarding to Mailpit:

version: '3.8'

services:
  localstack:
    image: localstack/localstack
    ports:
      - "4566:4566"
    environment:
      - SERVICES=ses
      - DEBUG=1

  ses-viewer:
    build:
      context: https://github.com/veertech/localstack-aws-ses-email-viewer.git#main
    ports:
      - "3005:3005"
    environment:
      - LOCALSTACK_HOST=http://localstack:4566
      - SMTP_FORWARD_ENABLED=true
      - SMTP_FORWARD_HOST=mailpit
      - SMTP_FORWARD_PORT=1025
    depends_on:
      - localstack
      - mailpit

  mailpit:
    image: axllent/mailpit:latest
    ports:
      - "8025:8025"  # Web UI
      - "1025:1025"  # SMTP server

With this setup:

Using the Viewer

Viewing Email Lists

The home page displays all emails in a table with:

Emails appear newest-first, making it easy to find recent test emails.

Inspecting Individual Emails

Click “View” to see the full email rendered in your browser. The detail view shows:

For emails with RawData, a “Download” link saves the complete email as an .eml file. Open it in any email client for additional inspection.

Viewing Attachments

When emails include attachments, they’re listed at the top of the detail view. Click any attachment to view or download it. The viewer sets proper content types, so images display inline and PDFs open in the browser.

Accessing the Latest Email

For automated testing, the /emails/latest endpoint returns just the most recent email’s HTML content:

curl http://localhost:3005/emails/latest

This is useful in integration tests:

// Send email through your app
await sendWelcomeEmail('user@example.com');

// Verify it was sent
const response = await fetch('http://localhost:3005/emails/latest');
const html = await response.text();
expect(html).toContain('Welcome to our service');

How SMTP Forwarding Works

When SMTP_FORWARD_ENABLED=true, the viewer acts as a bridge between LocalStack and Mailpit:

async function fetchMessages() {
  const response = await fetch(apiUrl);
  const data = await response.json();
  const messages = data["messages"];

  // Forward new messages to SMTP if enabled
  await smtpForwarder.forwardMessages(messages);

  return messages;
}

The forwarder:

  1. Tracks which messages have already been forwarded (prevents duplicates)
  2. Extracts the raw email data (RawData field)
  3. Sends it to Mailpit via SMTP using nodemailer
  4. Preserves all email properties: headers, attachments, HTML, text

This means:

The viewer essentially acts as an SMTP relay that understands LocalStack’s API format and translates it to standard SMTP.

Implementation Details

The viewer is a straightforward Express.js application that:

  1. Polls LocalStack’s /_aws/ses endpoint
  2. Parses emails using the mailparser library
  3. Renders results with Pug templates

Core functionality in under 200 lines:

app.get("/", async (_req, res, next) => {
  try {
    const messages = await fetchMessages();
    const messagesForTemplate = await Promise.all(
      messages.map(async (message, index) => {
        let email = await createEmail(message, index);
        email.id = index;
        return email;
      })
    );
    res.render("index", {
      messages: messagesForTemplate.reverse()
    });
  } catch (err) {
    next(err);
  }
});

The simplicity makes it easy to fork and customize for specific needs. Want to add search? Filter by recipient? Export to different formats? The codebase is small enough to modify in minutes.

Choosing the Right Approach

Use LocalStack Web UI (Pro) when:

Use the Custom Viewer + Mailpit when:

Use the Custom Viewer alone (without Mailpit) when:

Wrap-up

Testing emails in local development no longer requires compromises. LocalStack captures the emails. The custom viewer makes them accessible through both a simple API and optional Mailpit integration. The workflow becomes: write code, trigger email, verify in browser.

Two main paths exist:

  1. LocalStack Pro’s Web UI - All-in-one solution if you have a subscription
  2. Custom Viewer + Mailpit - Best choice for LocalStack Community users who want powerful features

The second approach is particularly compelling because:

The key insight: don’t skip email testing just because it’s traditionally been difficult. Modern tools make it as straightforward as any other feature test. The custom viewer bridges the gap between LocalStack’s SES API and Mailpit’s powerful interface, giving you the best of both worlds.

Resources