Fake SMTP server vs. a real test inbox: pick the right layer.
Four different tools all answer to "fake SMTP server for testing": mocked mailers, local SMTP traps like Mailpit, a shared qa@ mailbox, and hosted inbound inbox APIs. Each sits at a different layer, and each is structurally blind to everything outside its layer. This guide is the decision: what each one proves, where each one belongs, and which bug class you're accepting with each choice.
The short answer. Use a mock in unit tests, a local SMTP trap like Mailpit in local development, and a hosted inbound inbox API in CI and against staging. A trap proves your app called send; only a real, MX-routable address proves the email arrived — through your actual provider, DNS, and templates. And never use a shared real mailbox in an automated suite: it's mutable state shared between tests, and shared mutable state eventually lies.
The failure that sends people here
A team has a solid local setup: the app's SMTP config points at Mailpit, every signup email shows up instantly in its UI, and the e2e suite asserts against its REST API. Green for months.
Then staging ships a config change — the email provider's API key rotated, and the new one was pasted into the wrong environment. Signup emails silently stop arriving for everyone using staging. The test suite stays green the whole time, because the suite never tested staging's delivery path. It tested a socket on localhost. The trap did exactly what it promises — caught everything the app handed to the transport — and the bug lived one layer further out, in the provider config the trap replaces.
That's not a Mailpit bug. It's a category boundary. The mistake isn't using a fake SMTP server; it's using one layer for a job that belongs to another.
The four layers
1. Mocks and stubs — "did my code try to send?"
The innermost layer: stub the mailer interface in unit tests and assert it was called with the right recipient and template. No SMTP involved at all.
- Proves: your business logic decided to send, with the right arguments.
- Blind to: everything after the function call — rendering, transport, delivery, content.
- Right for: unit tests. Fast, deterministic, thousands per second. If the assertion is "the reset handler enqueues one email," a mock is the correct tool and everything below is overkill.
- Wrong for: any assertion about what the email contains or whether it arrives. A mock passes happily while your template engine throws on a missing variable.
2. Local SMTP traps — Mailpit, MailHog, and friends
You point your app's SMTP config at a local socket; the trap accepts everything, stores it, and shows it in a web UI with a REST API. Mailpit is the current standard here — free, open source, a single binary, works offline. MailHog was its predecessor: its repository is not formally archived, but its last release was in 2020, its last commit activity was in 2024, and Mailpit's maintainer describes it as no longer maintained. For a new install, use Mailpit.
- Proves: your app handed a fully rendered message to the transport. You can eyeball the HTML, check headers, catch a broken template before committing.
- Blind to: everything past the socket. The message never touches DNS, never meets your provider's API, never exercises the config your deployed environments actually use. And a trap only works where you control the SMTP config — you can't point a preview deploy on someone else's platform at your laptop.
- Right for: local development. Genuinely the best tool in its category for that job: instant feedback, zero cost, private, offline. If mail must never leave your network — air-gapped, compliance — this layer isn't just right but mandatory.
- Wrong for: proving delivery, and awkward in parallel CI. A single shared trap instance is shared state across workers (you're back to filtering by recipient and hoping); an instance per worker is more ops. And a trap accepts everything, so it can't catch the bug where your app sends to a mistyped recipient domain — a real MX would have told you.
3. Shared real mailboxes — qa@yourcompany.com, Gmail + IMAP
The tempting shortcut: a real mailbox, real delivery, no new tools. Sign up with qa+test42@company.com, poll IMAP, grep the body.
- Proves: real delivery, end to end. That part is genuine.
- Blind to: nothing technically — it fails on hygiene, not visibility. One mailbox is mutable state shared by every test, every worker, every branch, and yesterday's run. Yesterday's verification email matches today's assertion. Two parallel workers steal each other's messages. The mailbox accumulates thousands of messages and IMAP search slows down. Provider rate limits and bot detection throttle logins from CI IPs. And the credentials to a real corporate mailbox now live in your CI secrets.
- Right for: a one-off manual check. That's about it.
- Wrong for: any automated suite. This layer has real delivery and is still the worst option on the list, because test isolation matters more than almost anything else in a suite you want to trust.
4. Hosted inbound inbox APIs — real addresses, per test
The outermost layer: a service that owns real domains with real MX records and hands your tests fresh, isolated inboxes over an API. Your app sends through whatever it normally sends through — SES, Postmark, Resend, your staging config, anything — and the test asserts on what actually arrived. MailFixture is this layer, and only this layer: it receives email for your tests; it never sends, relays, or forwards anything.
- Proves: the whole path. Provider config, DNS, the template your provider's pipeline actually rendered, recipient validation, delivery itself. Plus receive-side facts a trap can't see: whether the message authenticated (SPF/DKIM/DMARC verdicts on every message) and how SpamAssassin scores it.
- Blind to: how Gmail or Outlook will render the message, and whether Gmail will inbox it — no hosted test inbox can honestly promise either. It also can't work air-gapped: mail has to leave your network to arrive somewhere real.
- Right for: CI and any test against a deployed environment. Because the addresses are real, there's nothing to rewire — a staging server, a preview deploy, a QA environment someone else owns, a mobile build hitting real APIs: if it can send email to an address, you can test it. Inbox-per-test isolation makes parallel workers a non-issue.
- Wrong for: watching emails render while you build (slower loop than a local trap, and it costs money at volume), and anything that must stay on your network.
The decision table
What the outer layer looks like in a test
The same shape you already write against a trap's REST API — create somewhere for the mail to land, trigger the flow, assert — except the address is real, so the identical test runs unchanged against localhost, staging, or a preview deploy.
import { test, expect } from '@playwright/test'; import { MailFixture } from 'mailfixture'; const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY test('signup delivers a one-time code', async ({ page }) => { // fresh, private, expires itself — no teardown races const inbox = await mail.createInbox({ ttlSeconds: 900 }); await page.goto('/signup'); await page.fill('#email', inbox.address); // a real, routable address await page.click('text=Send code'); // long-poll: returns the moment mail lands; 30s is a ceiling, not a duration const otp = await inbox.waitForOtp({ timeout: 30_000 }); await page.fill('#otp', otp); await expect(page.locator('h1')).toHaveText('Welcome'); });
from mailfixture import MailFixture mail = MailFixture() # reads MAILFIXTURE_API_KEY inbox = mail.create_inbox(ttl_seconds=900) trigger_signup(inbox.address) # your app, your provider, real delivery otp = inbox.wait_for_otp(timeout=30.0) # seconds in Python, ms in JS assert len(otp) == 6 inbox.delete()
Or raw REST, if you're not on JS or Python:
# create an inbox curl -X POST https://api.mailfixture.com/v1/inboxes \ -H "Authorization: Bearer $MAILFIXTURE_KEY" # long-poll for the message (held open server-side up to 45s) curl "https://api.mailfixture.com/v1/inboxes/INBOX_ID/messages?wait=45" \ -H "Authorization: Bearer $MAILFIXTURE_KEY"
Keep the key in a CI secret (MAILFIXTURE_API_KEY), never in the repo. And note what's absent from the code: no polling loop, no sleep, no regex over the body. The wait is held open server-side and resolves when the message arrives; the OTP comes back as a typed field, ranked by context so the copyright year in the footer doesn't win.
Failure modes by layer
Edge cases worth deciding up front
- Parallelism. Inbox-per-test makes worker count irrelevant. With a trap, decide explicitly: one instance per worker (ops) or recipient-filtering on a shared one (fragile). The parallel guide reproduces the race.
- Timeout budgets. Whatever waits on email needs a test-runner timeout larger than the email wait's deadline, or the runner kills the test before the wait can report a useful error.
- Cleanup and retention. Traps grow until you prune them. Hosted inboxes should expire themselves — create with a TTL (
ttlSecondsabove) or delete in teardown, so a crashed run leaves nothing behind. - Secrets. A trap needs none; a shared mailbox puts real corporate credentials in CI; an inbox API needs one revocable API key. Treat that as part of the comparison.
- Test what you own. Real, routable addresses are for testing your own product's flows. Registering accounts on services you don't control is a different activity, and one any legitimate provider's terms prohibit — including ours.
What this comparison doesn't tell you
Honesty cuts both ways. No layer here — including the outermost — proves inbox placement (whether Gmail puts you in Promotions or spam) or client rendering (how Outlook mangles your CSS). Those need seed-list and screenshot tools respectively, which are a different product category. A hosted inbox proves arrival, content, links, authentication, and spam score; it does not screenshot Gmail.
Where to go next
- The full three-layer strategy with the four anti-flake rules: the automated email testing guide.
- The Mailpit comparison in table form, including the rows Mailpit wins: MailFixture vs. Mailpit.
- Wiring the CI layer into your framework: Playwright, Cypress, or pytest quickstarts — or the OTP flake anatomy in depth.
If your suite is at the point where "green" and "delivered" have diverged, that's the signal to add the outer layer. Keep the trap for localhost — it earned its place — and let CI assert on what actually arrived.