Most Jest email tests mock the mailer. You stub nodemailer, assert it was called with the right address, and move on. That proves your code tried to send an email. It does not prove the provider delivered it, that the template rendered a code, or that the code a user receives actually works.
When you want that end-to-end guarantee inside a plain Jest suite, receive a real email. This post shows the pattern with MailSink, including the Jest-specific timeout trap that makes half of these tests flaky.
TL;DR
Give the test a real disposable inbox from an API instead of mocking the mailer. Create an inbox, run your signup against its address, wait for the verification code, assert it. In Jest that is three calls plus one important detail: set a per-test timeout longer than your email wait, or Jest kills the test at its default 5 seconds before the mail arrives.
const API = "https://api.mailsink.dev/v1";
const auth = { Authorization: `Bearer ${process.env.MAILSINK_API_KEY}` };
test("signup emails a working verification code", async () => {
const inbox = await fetch(`${API}/inboxes`, { method: "POST", headers: auth })
.then((r) => r.json());
await signup(inbox.address); // your app's real signup path
const { code } = await fetch(
`${API}/inboxes/${inbox.id}/wait-for-code?timeout=30`,
{ headers: auth },
).then((r) => r.json());
expect(code).toMatch(/^\d{6}$/);
}, 40000); // <- Jest timeout MUST exceed the wait-for-code timeout
Why mocking nodemailer is not enough
Mocking is the right call for a lot of tests. It is the wrong call when the thing you actually care about is the delivery path. A mocked test stays green when:
- the production API key is revoked or wrong,
- the template variable for the OTP is misnamed and renders blank,
- the provider silently rate-limits or blocks the recipient domain,
- the code format changes and your parser breaks.
Each of those ships to users despite a passing suite. A test that receives the real email catches all four, because it asserts on the code a real inbox actually got.
The Jest timeout trap
This is the single most common reason these tests flake. wait-for-code is a long poll that can block up to your requested timeout. Jest’s default per-test timeout is 5 seconds. If the email takes 8 seconds to arrive, Jest aborts the test before your await resolves, and you get a misleading timeout failure that looks like a MailSink problem but is a Jest config problem.
Always set the test timeout (third argument to test) higher than the wait-for-code timeout:
test("...", async () => { /* ... */ }, 40000);
// or globally:
jest.setTimeout(40000);
A good rule: wait timeout 30s, Jest timeout 40s. The 10s of headroom absorbs network and setup time.
Inbox lifecycle with hooks
For a suite that runs several email assertions, provision one inbox per test rather than sharing. A shared inbox means one test can read another test’s mail. beforeEach keeps them isolated:
let inbox;
beforeEach(async () => {
inbox = await fetch(`${API}/inboxes`, { method: "POST", headers: auth })
.then((r) => r.json());
});
test("password reset sends a link", async () => {
await requestPasswordReset(inbox.address);
const { link } = await fetch(
`${API}/inboxes/${inbox.id}/wait-for-link?timeout=30`,
{ headers: auth },
).then((r) => r.json());
expect(link).toContain("/reset?token=");
});
There is no afterEach cleanup to write. MailSink deletes inbox contents after the TTL (1 hour on the free tier), so nothing accumulates between runs.
Codes and links, parsed for you
wait-for-code returns the OTP as { code } and wait-for-link returns the verification URL as { link }. The API extracts these from the message so your assertion works on a value, not an HTML body. Senders like Stripe, GitHub, Clerk, Supabase, and Auth0 are tuned; for anything unrecognised you can read the raw message and parse it yourself. The API docs list every endpoint.
What this costs
Each test creates one inbox. The free tier is 50 inboxes per month, which covers a signup suite that runs a few times a day. A busy CI pipeline that runs the full suite on every push moves to the $15/mo Pro plan (2,000 inboxes per month). Because inboxes are per-test and short-lived, you are not paying for idle mailboxes.
FAQ
Should I mock email or use a real inbox in Jest?
Mock when the test only cares that your code calls the mailer with the right arguments. Use a real inbox when the test needs to prove the end-to-end path works: real delivery, real template rendering, a real code a user could type. Both belong in a suite, at different layers.
Why does my Jest email test time out?
Almost always because the Jest per-test timeout is shorter than the wait-for-code timeout. Jest defaults to 5 seconds. Set the third argument of test() (or jest.setTimeout) higher than your wait timeout, for example 40 seconds against a 30 second wait.
Do I need a browser or Puppeteer for this?
No. The inbox API is plain HTTP, so a backend Jest test that calls your signup route directly works without a browser. If you are testing the UI, the same three calls slot into a Puppeteer or Playwright test.
How do I avoid tests reading each other’s email?
Create one inbox per test in beforeEach instead of sharing a single inbox across the suite. Each address only receives the mail that test triggered.
Is there an npm SDK?
Not yet. You call the REST API directly with fetch, which keeps the dependency surface small. Agents can use the MCP server instead of HTTP.
Next
- Try MailSink free (50 inboxes/month, MCP included)
- Test email verification in Cypress
- Test email verification in GitHub Actions CI
- Verify OTP in Playwright without regex