Skip to content

← All writing

For developers

Testing signup emails in CI without a real mailbox

31 July 2026 · 2 min read · TMailr

Sooner or later a test needs to click a verification link, and the usual answers are all bad. A shared team mailbox becomes a race between builds. A personal mailbox makes one engineer the dependency. Mocking the send tests everything except the part that breaks.

What actually needs to be real

Mocking your mail client proves your code called a function. It cannot tell you that the template renders, that the link points at the right host, that the code in the body matches the one in the database, or that the message authenticates on arrival. Those are the failures that reach users, and they all live past the mock.

So the message should be sent for real, and received somewhere a test can read without a human being involved.

One address per test, from a catch-all

The pattern that works is a sandbox domain where every local part already exists. A test invents an address, uses it, and reads it back, without creating anything first and without cleaning anything up.

const box = await tmailr.sandbox();
const email = box.addressFor(`signup-${runId}`);

await signUp(email);
const msg = await box.waitForMessage({ to: email, timeout: 30_000 });

expect(msg.otp).toBe(await db.codeFor(email));

Because the address is derived from the test rather than shared, parallel runs cannot collide, and a failing test leaves evidence addressed only to itself.

Wait for the message, do not sleep

The commonest flake in this area is a fixed sleep. Mail takes as long as it takes: usually under a second, occasionally ten when a queue is busy. A three-second sleep passes on a quiet machine and fails on a loaded one, which is the definition of a flaky test.

Poll or subscribe, with a timeout that is generous, and fail with the inbox contents in the message. A test that says timed out waiting for mail is a bad afternoon; one that says timed out, and here are the two messages that did arrive is thirty seconds.

Assert on what will break

  • The code in the message is the code in your database, not merely a six-digit number.
  • Every link points at the environment under test, because a staging email carrying production URLs is a real and embarrassing bug.
  • The From address and display name are what you intended.
  • SPF, DKIM and DMARC passed, if your test environment sends the way production does.
  • There is a plain-text part, and it makes sense on its own.

Keep it out of the way

Two practical things. Give the suite its own sandbox rather than sharing one with anybody doing manual testing, so a stray human message cannot fail a build. And make the address include the run identifier, so that when something does fail you can find the exact message from the log line without guessing.

The goal is a signup test that exercises the whole path, including the mail, and still finishes in under a minute without a person owning a mailbox.

More on for developers