Amazon SES is the go-to transactional email service for teams already in the AWS ecosystem. But testing SES emails end-to-end in CI is notoriously painful — IAM permissions, sandbox restrictions, and no easy way to catch sent emails programmatically.
This guide shows the complete setup for testing Amazon SES email flows in Playwright using ZeroDrop — no Docker, no shared inboxes, no mocking.
The app we're testing
A Next.js API route that sends a verification email via Amazon SES:
// app/api/auth/signup/route.ts
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
const ses = new SESClient({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function POST(req: Request) {
const { email } = await req.json();
const token = crypto.randomUUID();
const verifyUrl = `${process.env.NEXT_PUBLIC_URL}/verify?token=${token}`;
await ses.send(new SendEmailCommand({
Source: 'noreply@yourapp.com',
Destination: { ToAddresses: [email] },
Message: {
Subject: { Data: 'Verify your email' },
Body: {
Html: {
Data: `<p>Click <a href="${verifyUrl}">here</a> to verify your email.</p>`,
},
},
},
}));
return Response.json({ success: true });
}
Stage 1 — Local development: SES sandbox mode
When you first create an Amazon SES account, it starts in sandbox mode. In sandbox mode:
- You can only send emails to verified email addresses
- You can only send emails from verified email addresses or domains
- There's a sending limit of 200 emails per day
This is fine for local development — verify your own email address in the SES console and use it as the test recipient. But sandbox mode completely blocks automated CI testing because you can't verify a randomly generated @zerodrop-sandbox.online address.
What it solves: Basic local testing with a fixed test email address.
What it doesn't solve: Automated testing with random addresses, parallel test runs, or CI pipelines.
Stage 2 — Request production access
To use SES with ZeroDrop in CI, you need to move out of sandbox mode. Request production access in the AWS SES console:
- Go to SES → Account dashboard
- Click Request production access
- Fill in your use case — "transactional email for application testing"
- AWS typically approves within 24 hours
Once approved, SES can send to any email address including ZeroDrop inboxes.
Stage 3 — CI: SES production + ZeroDrop
npm install zerodrop-client
import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';
const mail = new ZeroDrop();
test('user can sign up and verify email', async ({ page }) => {
// 1. Generate a disposable inbox
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
// → "swift-x7k2m@zerodrop-sandbox.online"
// 2. Sign up — SES sends a real verification email to this inbox
await page.goto('/signup');
await page.fill('[data-testid="email"]', inbox);
await page.click('[data-testid="submit"]');
await expect(page).toHaveURL('/check-email');
// 3. ZeroDrop catches the email — magic link auto-extracted
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.subject).toContain('Verify your email');
expect(email.magicLink).not.toBeNull();
// 4. Click the verification link
await page.goto(email.magicLink!);
// 5. Assert verified
await expect(page).toHaveURL('/dashboard');
});
OTP flows
await ses.send(new SendEmailCommand({
Source: 'noreply@yourapp.com',
Destination: { ToAddresses: [email] },
Message: {
Subject: { Data: 'Your verification code' },
Body: {
Html: { Data: `<p>Your code is: <strong>${otp}</strong></p>` },
},
},
}));
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
// OTP auto-extracted at the edge — no regex needed
expect(email.otp).not.toBeNull();
await page.fill('[data-testid="otp"]', email.otp!);
await page.click('[data-testid="verify"]');
IAM permissions for CI
Create a dedicated IAM user for CI with minimal permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail"
],
"Resource": "*"
}
]
}
Store AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as GitHub Actions secrets — never in your codebase.
GitHub Actions workflow
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Generate test inbox
id: inbox
uses: zerodrop-dev/create-inbox@8706a59 # v1.0.0
- name: Run E2E tests
run: npx playwright test
env:
TEST_INBOX: ${{ steps.inbox.outputs.inbox }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
NEXT_PUBLIC_URL: ${{ secrets.STAGING_URL }}
Pro tip: Use GitHub's OIDC integration with AWS IAM roles instead of long-lived access keys — more secure and no key rotation needed.
The full picture
| SES sandbox (local) | SES production (staging) | SES production + ZeroDrop (CI) | |
|---|---|---|---|
| Verified recipients only | ✅ (limitation) | ❌ | ❌ |
| Random test addresses | ❌ | ✅ | ✅ |
| Automated in CI | ❌ | ❌ | ✅ |
| Parallel test runs | ❌ | ❌ | ✅ |
| OTP auto-extraction | ❌ | ❌ | ✅ |
| Tests real delivery | ❌ | ✅ | ✅ |
Request production SES access early — it unlocks automated testing and takes 24 hours to approve. Once approved, the live SES + ZeroDrop combination gives you full E2E coverage in CI.
ZeroDrop — disposable email inboxes for CI pipelines. Free, no signup, no Docker.
→ zerodrop.dev · docs · npm
Top comments (0)