Testing Email End to End with a Disposable AWS SES Inbox
How I built a receive-only test inbox on Amazon SES, S3, SNS, and Lambda, so end-to-end tests can prove who got an email, including Bcc.
Engineering. Updated . 3 min read.
My end-to-end tests could prove the app called the email provider. They couldn't prove an email arrived, or who got it. A mocked sender proves a code path, not a delivery. So I built a small inbox that only tests can read.
TL;DR: point a test subdomain's MX record at Amazon SES receiving. SES drops each raw message in a private S3 bucket and notifies SNS, which triggers a Lambda that writes one tiny index file per recipient. A test generates a unique address, triggers the email, and polls the index for that address with a read-only role.
The flow
- The test generates an address like
e2e-<run>-<actor>@<test subdomain>. - The app sends a real email to it.
- SES receives it and writes the raw MIME to
raw/<receiptId>in S3. - SES publishes to SNS, and SNS invokes a Lambda.
- The Lambda writes
index/<sha256(recipient)>/<receiptId>.jsonfor each envelope recipient. - The test lists its own index prefix until the message shows up or a deadline passes, then reads the raw message.
DNS-wise it's an MX record and the SES verification TXT record on a subdomain. The main domain's mail never notices.
The decisions that mattered
Index by the envelope, not the headers. This is the whole reason the thing works for Bcc. A Bcc address isn't in the headers, by design, so no amount of header parsing will find it. The SES receipt carries the envelope recipients. Index on those and a test can finally prove a Bcc recipient got the email.
Hash the recipient in the key. SHA-256 of the lowercased address gives the key a fixed shape, and Ahmad@ versus ahmad@ can't split one inbox into two.
Write each index file exactly once. The Lambda uses a conditional put (If-None-Match: *). A retry of the same receipt finds the file already there, and that counts as success. But only that specific precondition failure counts. Any other error fails the invocation, so a real failure never gets waved through as "probably a duplicate."
No destructive reads. A queue that tests consume was the obvious alternative, and I rejected it. Tests run in parallel, and one test would eventually eat the message another test was waiting for. With a write-once index, any number of readers can list and read, and the evidence sticks around.
Strict addresses. Generated addresses match e2e-[a-z0-9][a-z0-9+-]* and stay under 64 bytes. Each carries a run ID and an actor ID, so two runs never share an inbox.
Access and guardrails
- Tests get a read-only role. A write attempt returns
AccessDenied. - CI assumes it through GitHub OIDC, trusted for exact branch subjects only. No wildcards, no pull request subject, so a fork can't read the inbox.
- Raw messages and index files expire after 7 days.
- A run stops at 200 messages, and a poll gives up at 120 seconds.
- A monthly cost alert watches SES spend. Worth being honest about: it's an alert, not a cap, and it doesn't cover S3, SNS, or Lambda. The per-run limit is the actual guard.
- Rollback is one switch: disable the receipt rule.
Proving it before trusting it
Before any test depended on it, a smoke run sent one email with To, Cc, Bcc, a text part, an HTML part, and an attachment. The reader role then checked every part, the attachment bytes, and encryption at rest. The Bcc address was in the receipt and absent from the headers, exactly as it should be. And a write through the reader role got AccessDenied.
Questions
- How do I test that my application really sent an email?
- Receive the email on a domain you control and read it in the test. Amazon SES can receive mail for a subdomain and store the raw message in S3. The test then polls for the message by recipient and asserts its content.
- How can a test prove a Bcc recipient got the email?
- Only from the delivery envelope. A Bcc address is not in the message headers by design. The SES receipt lists the envelope recipients, so index each message by those recipients, not by the To header.
- Why not read test emails from a queue?
- A queue read is destructive. When tests run in parallel, one test can consume the message another test needs, and the evidence is gone. A write-once index that many readers can list does not have this problem.
- How much does an SES test inbox cost?
- Very little at test volume. Set a monthly cost alert anyway. An AWS budget alert tells you about spend. It does not stop it, so also cap the messages per test run in your own code.