GUIDES / LAYER CHOICE

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.

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.

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.

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.

The decision table

ENVIRONMENTRECOMMENDED LAYERWHY
Unit testsMock the mailerYou're testing logic, not email. Milliseconds matter.
Local developmentSMTP trap (Mailpit)Instant visual feedback, free, offline. The trap's home turf.
CI, app runs inside the pipelineTrap or inbox APIIf the whole stack runs in Docker Compose in the job, a trap works — until you need parallel isolation, delivery proof, or auth/spam verdicts. Then move out a layer.
CI against staging / preview deploysHosted inbound inbox APIYou can't point someone else's deployment at your socket. Real addresses need no rewiring.
Staging / pre-release verificationHosted inbound inbox APIThis is where provider-config and DNS bugs live. Only real delivery catches them.
Anywhere, everShared real mailboxDon't. Shared mutable state plus IMAP scraping is the flakiest option with the most credentials at risk.
THE PATTERN Capture locally, deliver in CI. The layers compose — keep Mailpit for the inner loop and assert real delivery in the pipeline. Nothing about adopting an inbox API asks you to drop the trap.

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.

signup.spec.ts — Playwright shown; the SDK is framework-agnosticnpm i -D mailfixture
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');
});
the same flow in Pythonpip install mailfixture
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:

the same flow in curl
# 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

SYMPTOMLAYER AT FAULTEVIDENCEFIX
Suite green, staging users get no emailTrap used beyond localhostTrap UI shows the mail; provider dashboard shows no sendAssert real delivery from CI with an inbound inbox layer
Test passes with last run's emailShared mailboxMessage timestamp predates the test runInbox-per-test; assert only on messages newer than the trigger
Parallel workers randomly steal messagesShared mailbox or shared trap instanceFailures correlate with worker count, vanish seriallyOne inbox (or trap instance) per test/worker
Mock-covered flow ships a broken templateMock used where content mattersFine in unit tests; template variable missing in the sent mailAdd a trap (local) or real-inbox (CI) assertion on actual content
Email "works" but lands in spam for real usersAny capture layer — traps accept everythingTrap shows the mail; no SPF/DKIM/DMARC ever evaluatedAssert receive-side auth verdicts on a real delivery path
Test can't reach the preview deploy's email at allTrap — nothing to rewirePreview platform exposes no SMTP configReal addresses: give the deploy an inbox address, no config needed

Edge cases worth deciding up front

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.

RECEIVE-ONLY MailFixture is the receiving end of the wire by design: it never sends, relays, or forwards mail, so it's not a replacement for your sending provider — it's the far end your tests get to observe.

Where to go next

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.

100 messages/mo free · keeps playing fine alongside Mailpit
Start free Read the quickstart