How to Test Signup and Verification Emails as a Developer

By the MailboxTemp Editorial Team · Published · Updated

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

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

For polling an inbox from a test runner in more depth, see disposable email for developers.

Sources

Frequently asked questions

Can I just mock the email sending in my tests?

Mock it for unit tests, where you are checking the message your code produces. But a mock can never tell you whether mail arrives, how long it takes, whether it authenticates, or whether the receiving filter accepted it, because none of that is in your code. Keep a small number of real end-to-end sends for those questions and run them on a schedule rather than on every commit.

Why use a disposable address instead of a dedicated test mailbox?

A disposable inbox gives you a unique address per test run with no setup, which is what stops a stale message from satisfying a new assertion. A single fixed mailbox has to be emptied between runs, and if that cleanup ever fails silently, a broken send will keep passing. It is also an address you can safely print in a CI log.

What should I assert on besides the email arriving?

That exactly one message arrived, that it came within a time budget you chose deliberately, that the From address and display name are what you expect, that the token in the link matches the database record, and that Authentication-Results shows SPF, DKIM and DMARC passing with the DKIM signing domain aligned to your From domain.

How do I stay under the ten inboxes per day limit in CI?

Create one inbox per pipeline run, pass its address to every job through an output or environment variable, and send every message of that run to it. Match messages by subject or token rather than position. If a run still hits the cap, the API returns 429 DAILY_LIMIT_REACHED with a resetsAt timestamp at midnight UTC; treat that as a skipped test, not a failed one.

Does staging need its own SPF and DKIM records?

Yes, on its own sending subdomain. Sharing the production domain means test traffic affects real reputation and muddies your DMARC reports. Sending with no authentication at all teaches the team to ignore failures, which is how a real production misconfiguration goes unnoticed. Give staging the same setup as production with DMARC at p=none and a reporting address.

Get a free temporary inbox →