As a developer, to test signup emails properly you let your application send a real message to a real mailbox you control, then check four separate things: that it arrived at all, how long it took, whether it passed the receiving server's authentication checks, and whether the link inside it still works when clicked from a browser that has never seen your app. Mocking the send and asserting that a function was called covers none of those. That is a useful test, but it tests your code, not your email.
The gap between the two is where most verification bugs live. A template renders perfectly in a unit test and arrives with a dead link, because the URL was built from the request host and staging sets that differently. Mail that sends in milliseconds locally takes minutes through a provider's queue, and the user has already clicked "resend" twice. A DKIM selector typo sits unnoticed for a month because nothing in the test suite ever looked at a header.
Three layers, and what each one can actually catch
Unit level: the message your code produces
Render the template with a fixture, assert on the result. This is where you check that the token is present, that the greeting handles a missing first name, that the unsubscribe block appears on marketing mail and not on transactional mail, and that generating a second code invalidates the first. These tests are fast and deterministic, so run them on every commit. They can tell you nothing whatsoever about delivery.
Integration level: the real MIME message, captured locally
Point your app at a local SMTP sink (Mailpit, MailHog, smtp4dev or your provider's sandbox mode) and it will accept and store the message without delivering it. You now have the actual bytes your mail library produced. This is the cheapest place to catch a missing text/plain alternative, a malformed multipart boundary, or a subject line that breaks on an accented character. A local sink is the complement to a disposable inbox, not a rival: it shows you the message you sent, while the inbox shows you the message that arrived.
End-to-end: over the public internet, into a real inbox
Only this layer exercises DNS, TLS negotiation, your sending reputation, SPF and DKIM verification, and the receiving spam filter. It is also slow, dependent on a third party, and occasionally flaky, so keep these tests few, explicit and separate from the suite developers run before pushing.
Never put real addresses in test data
Before any of that, fix your test data. Seed fixtures and factories should produce addresses at a domain that cannot receive mail: example.com, example.org and example.net are reserved by RFC 2606 for exactly this purpose. Never a colleague's personal address, and never an address copied out of production.
The failure this prevents happens regularly: a staging database is refreshed from a production dump, a scheduled job notices a few thousand accounts with no welcome email logged, and it does its job. The defence is a gate in the transport layer, not developer discipline. In any non-production environment, make your mail transport check the recipient against an allowlist of domains you own or control, and hard-fail anything else.
Where do throwaway inboxes fit?
For the end-to-end layer you need a destination with three properties: it accepts mail immediately with no account setup, you can read it programmatically, and you do not care that its address ends up in a CI log that half the company can see. A disposable inbox fits all three.
The detail that catches people out is uniqueness per run. If every nightly run sends to the same address, yesterday's message can satisfy today's assertion and a completely broken send will look green for weeks. Generate a fresh address per run, so that a stale message can never pass for a new one. On a catch-all domain any local part works without being created first; with an API, you ask for one and get it back. Our note on catch-all addresses explains the first option; the second is below.
A runnable workflow with the MailboxTemp API
Everything the MailboxTemp inbox page does goes through a public HTTP API with no key. We ran this exact sequence against our own server while writing this article; the output below is what came back, unedited apart from line wrapping. Step one creates the inbox:
POST /api/inbox/generate
{"ok":true,"address":"wrl4u9a@fakhita.xyz",
"expires":"2026-11-01T17:07:14.133Z",
"inboxId":"c50ac5dd-72d5-4ceb-b6be-a0aabf8196fc"}
Step two is yours: register wrl4u9a@fakhita.xyz in the flow under test so your app sends it a real verification email. Step three polls the inbox. The listing already contains the detected one-time code, so most tests never need the full body:
GET /api/inbox/wrl4u9a@fakhita.xyz/emails
{"ok":true,"emails":[{"id":"b636321c-ee9e-474f-ab4a-3c1591d74e7b",
"from_address":"verify@mailboxtemp.com","from_name":"MailboxTemp test",
"subject":"[internal test] Your verification code","otp_code":"482913",
"has_attachments":false,"read":false,
"received_at":"2026-11-01T16:07:24.639Z"}]}
GET /api/email/{address}/{id} returns the same record plus body_text, a sanitised body_html and attachment metadata, which is where you extract the link. In our run the receiving server accepted the message at 16:07:24.654Z and the API listed it 333 milliseconds later; the first poll a quarter of a second after the SMTP handshake returned an empty list, the second returned the message. That is the floor for arrival time on a quiet server, not a promise.
The repository ships this as a script, examples/signup-email-test.js: it creates the inbox, runs whatever command you give it with {address} substituted, polls for up to 90 seconds and exits 0 or 1 so it drops straight into CI. This is its real output from the same session, with a small SMTP sender standing in for "your app":
$ node examples/signup-email-test.js --send "node ./send-code.js {address}"
inbox: l55fqn3@fakhita.xyz (expires 2026-11-01T17:08:07.992Z)
running: node ./send-code.js l55fqn3@fakhita.xyz
{
"from": "verify@mailboxtemp.com",
"subject": "[internal test] Confirm your account",
"receivedAt": "2026-11-01T16:08:08.123Z",
"otp": "735201",
"firstLink": "https://example.com/verify?token=735201",
"waitedMs": 2095
}
The 2,095 ms is the script's two-second poll interval, not delivery time. Inside a test, assert on four things from that JSON: otp or the token in firstLink equals the one your database issued; the list length is exactly one; from is the configured sender; and waitedMs is inside a budget you chose, so a provider queue that silently grows to two minutes fails the build instead of the user.
Living with the limits
- Ten inboxes per IP per day on the free tier. CI runners share egress addresses, so create one inbox per pipeline run and send every message of that run (signup, resend, password reset) to it; the listing is newest first, so match on subject or on the token rather than taking
emails[0]. The eleventh request returns429 {"error":"DAILY_LIMIT_REACHED","limit":10,"resetsAt":…}, with the reset at midnight UTC; treat it as a skip, not a failure. - Twenty generate calls per minute. Parallel jobs should share the address through a job output or an environment variable rather than each creating their own.
- Sixty minutes, then gone.
GET /api/inbox/{address}returnsttlin seconds. A suite that runs longer than that callsPOST /api/inbox/{address}/extend, which adds ten minutes per call; we measuredexpires_atmoving from17:07:14to17:17:14on one call. - Blocked disposable domains. If your own signup form rejects disposable domains, the end-to-end test needs an allowlist entry for the test domain in non-production only, which is itself worth a test.
Why read the headers, not just the body?
The headers are where the delivery answers live, and most test suites throw them away. Authentication-Results is the receiving server telling you whether SPF passed, whether the DKIM signature verified, and whether DMARC was satisfied; check the d= value on the DKIM result, because a signature from your provider's own domain verifies happily while failing alignment. The Received chain carries timestamps at each hop, which gives you real queue latency. Return-Path decides where bounces go.
Disposable inboxes, including ours, hand you the parsed message rather than the raw headers, so run this part of the check against a mailbox on the provider your users actually have. Our walkthrough on how to read email headers covers the structure, and the email header analyzer breaks out the authentication results and hop timings from a pasted source.
SPF, DKIM and DMARC on a staging domain
Give staging its own sending subdomain with its own SPF record and its own DKIM key, published the same way production's are. Otherwise staging either sends from the production domain, so test traffic and real traffic share one reputation and one set of DMARC reports, or it sends unauthenticated and everyone learns to ignore failures that "don't matter".
Keep staging's DMARC policy at p=none with a reporting address, so you receive the aggregate reports without anything being quarantined. And always verify a DNS change by sending a real message and reading the Authentication-Results it produces: a wrong selector is invisible in a zone file and obvious the moment mail flows.
Test the click, not just the email
The verification link is a second system and it deserves its own tests. Open it in a clean browser profile with no cookies, because a logged-in session can mask a flow that is broken for the person it was built for. Confirm the token is single-use, and test expiry by controlling the clock rather than sleeping through it.
Mail also passes through things that follow links before any human does: click-tracking wrappers, corporate link scanners and prefetchers. If your verification endpoint consumes the token on a plain GET, one of those visits can burn it. Make the GET idempotent and have the landing page confirm with a POST. A test that fetches the link twice and expects the flow to still complete keeps that fixed once you have fixed it.
In short
- Fixtures use reserved domains; the transport blocks every non-allowlisted recipient outside production.
- Assert on the rendered template in unit tests, on the real MIME message in a local sink, and on real delivery in a small nightly end-to-end job.
- One fresh inbox per run, every message of the run into it, matched by token rather than position.
- Assert exactly one message, the right token, the right sender, inside a time budget you chose.
- Check Authentication-Results including DKIM alignment at a mainstream mailbox; disposable inboxes return parsed fields.
- Staging gets its own sending subdomain, its own keys, and
p=nonewith reports. - Click the link from a cold browser, twice, and make sure a prefetch cannot burn the token.
For polling an inbox from a test runner in more depth, see disposable email for developers.
Sources
- RFC 2606, Reserved Top Level DNS Names, which reserves example.com, example.net and example.org.
- RFC 8601, Message Header Field for Indicating Message Authentication Status, the Authentication-Results header.
- RFC 7489, DMARC, including the identifier alignment rules.
- Mailpit documentation, a local SMTP sink for capturing test mail.
- SendGrid docs: Sandbox mode, an example of a provider-side send without delivery.
- MailboxTemp API documentation, the endpoints, limits and error codes quoted above.