Debugging a missing test email: the diagnostic tree.
Your test triggered a signup, called waitForOtp, and got a timeout. Somewhere between your app and your assertion an email went missing — and a timeout is the least informative failure in testing, because it doesn't say which of ten different things went wrong. This guide is the diagnostic tree: one decisive first check, then every branch with the observable evidence that proves you're on it.
The quick answer. A wait timeout means one of two things: the email never reached your test inbox, or it arrived and your wait didn't match it. Decide which first — list the inbox's messages with no filters (dashboard, or a bare API call). Messages present: your match, your since cursor, or your timeout budget is wrong. Empty: the send failed upstream — check your app's logs and your email provider's bounce list, in that order.
What the timeout actually tells you
Both SDKs raise a dedicated timeout type, distinct from API errors, and the distinction is the first diagnostic signal:
// JS/TS — MailFixtureTimeout (extends MailFixtureError, status === null) // "timed out: no message matching \"subject:verify\" within 30000ms" // "timed out: no message with an OTP within 45000ms" # Python — MailFixtureTimeout (subclass of MailFixtureError) # "timed out: no message matching 'subject:verify' within 30s"
A MailFixtureTimeout means the API answered normally the whole time — your key worked, the inbox exists, nothing matched before the deadline. A plain MailFixtureError carrying an HTTP status is a different tree entirely:
- 404
not foundon the inbox — the inbox doesn't exist under this API key. Either the id is stale (a previous run's inbox, since deleted or TTL-swept) or your CI is polling with a different key than the one that created it. Keys are scoped to a workspace; an inbox created under your staging key is a 404 under your production key. - 429 with
Retry-After— a rate limit. The SDK wait helpers absorb these automatically mid-wait, so if one is being raised it's from a hand-rolled polling loop. Stop rolling your own loop; that's whatwait=is for. - 403
forbidden— the account is frozen, or inbox creation hit the plan's active-inbox cap; thedetailfield says which.
If you got a clean MailFixtureTimeout, move to the fork.
The fork: did the email ever arrive?
List the inbox with no match, no since, no wait:
curl -s https://api.mailfixture.com/v1/inboxes/$INBOX_ID/messages \
-H "Authorization: Bearer $MAILFIXTURE_API_KEY"
Or open the inbox in the dashboard — the message list is live (it refreshes every few seconds) and unfiltered. One dashboard trick worth knowing: the Overview page's recent-messages feed is account-wide. If your app sent to a different inbox than the one your test is polling — a hardcoded address, a normalization bug — the message shows up there, and the wrong-recipient branch resolves in one glance.
Now take the branch.
Branch A: the inbox is empty — the email never landed
MailFixture is the receiving end. When nothing arrived, the evidence lives upstream: your application logs and your email provider's dashboard (Resend, SES, Postmark, SendGrid — whatever sends for you). Work outward from your app:
A1. The app never triggered the send. The most common cause and the least examined. The signup handler validated something and returned early, the email job went to a queue whose worker isn't running in CI, or the staging config points at a sandbox/suppressed mode. Evidence: your provider's dashboard shows no outbound message for that recipient at that timestamp. No send, no delivery — nothing MailFixture can show you will fix this branch.
A2. Wrong recipient. The app sent, but not to your test address. Classic variants: an address normalizer that strips +suffix or changes case in a way that produces a different string than the inbox you created, a form that trims at the wrong length, or a fixture file with a stale hardcoded address. Evidence: the provider log shows a successful send — to a different address. Cross-check the recipient in the provider log against inbox.emailAddress character by character.
A3. Provider-side rejection or suppression. The send was attempted and the provider refused or silently dropped it. The sneaky version: suppression lists. If an earlier run's send hard-bounced (every 550 in A4–A6 is a hard bounce), many providers suppress the address and silently skip future sends to it. Your app logs "sent", the provider logs "suppressed", and nothing arrives — forever, for that address. Evidence: the provider dashboard's bounce/suppression view. Fix: fresh inbox per test run; a suppressed address from Tuesday can't haunt Wednesday's random one.
A4. Unknown inbox — rejected with 550. MailFixture has no catch-all: mail to an address that no API call created is rejected during the SMTP session with a permanent 550 5.1.1 no such inbox, before the body is even transmitted. A typo in the local part, the wrong shared domain, or an inbox created in a different workspace all land here. Evidence: your provider records a hard bounce quoting the 550. The dashboard's inbox list confirms whether the address the provider tried actually exists in this workspace.
A5. The inbox's TTL expired. ttl_seconds is a self-destruct timer. The moment it passes, the address stops accepting mail — senders get the same 550 as for an address that never existed — and an hourly sweep then deletes the inbox entirely (after which your poll turns into the 404 above). If your test creates an inbox with a 60-second TTL and the app's email job takes 90 seconds, you built this failure yourself. Evidence: ttl_expires_at on the inbox is in the past; the provider shows a hard bounce timestamped after it. Fix: set the TTL to comfortably outlive the slowest plausible run — 10–15 minutes is the usual choice — and keep explicit deletion in your teardown as the primary cleanup. The full lifecycle is in the inboxes concept page.
A6. Quota rejections. Two different behaviors, deliberately:
- Daily receive cap (100/day on the free plan, higher on paid): senders get a transient
452. Well-behaved mail servers queue and retry, so the message often arrives late rather than never — which masquerades as a latency problem (branch B4) until you look at the meter. - Free-plan monthly quota (100 messages): a permanent
550hard stop. No retry will help, and it feeds the suppression trap in A3.
Evidence: the dashboard Overview's usage meter — it turns amber at 80% and red at 100% of the free quota — plus deferred (452) or bounced (550) entries in the provider log. Fix: upgrade, or wait for the window to reset; on paid plans the monthly quota bills overage instead of bouncing, so only the daily cap applies.
A7. Message too large. Messages over 10 MB are rejected during the SMTP session. Rare in test flows until someone attaches a debug bundle to the welcome email. Evidence: provider-side rejection; the size is in your own template.
Branch B: the message is there — your wait missed it
The unfiltered list shows the email sitting in the inbox, yet the wait timed out. Now the bug is in the query, and it's one of four things.
B1. match filter too strict. match is deliberately simple: a subject:, from:, or to: prefix followed by a case-insensitive substring; a bare term matches the subject. The common misses:
match: 'from:noreply@yourapp.com'while the provider actually delivers from a rewritten envelope domain likebounce.yourapp-mail.net. Compare against thefrom_addrfield of the message you can see in the list — that's the addressfrom:matches against.- Matching the old subject after a copy change.
subject:Verify your accountstops matching the day marketing ships "Confirm your email". - Forgetting the prefix:
match: 'noreply@yourapp.com'is a subject search.
Evidence: re-run the list call with your exact match value; the message vanishes from the result. Fix: loosen to a stable substring (subject:verif survives more copy edits than the full sentence), or drop match entirely on an inbox-per-test setup — a private inbox rarely needs filtering.
B2. since cursor in the future. since returns only messages received strictly after the timestamp. If you build that cursor from your own clock — new Date().toISOString() before triggering the send — a machine clock a few seconds fast excludes the message forever. Evidence: the message's received_at is earlier than your since value. Fix: never fabricate a cursor from the local clock. Use the received_at of the last message you saw, verbatim off the wire — that's what the SDK helpers do internally, which is why this bug only appears in hand-rolled loops and raw API usage.
B3. Duplicate / resend confusion — the wait matched the wrong message. The inverse failure: the wait didn't time out, it resolved instantly — with a stale message. The SDK's wait helpers start with no cursor, so the first poll returns everything already in the inbox. On a reused inbox, "wait for the OTP" happily returns the OTP from the previous run, your app rejects the expired code, and the failure gets misread as an app bug. Resend flows hit the same wall inside one test: trigger, resend, wait — and get the first email. Evidence: the returned message's received_at predates your trigger. Fixes, in order of preference: fresh inbox per test (nothing stale can exist); clear() the inbox before the resend step; or, when you specifically need "the second email", poll the raw list with a since cursor taken from the first message's received_at.
B4. Latency vs. wait budget. The email arrived — 70 seconds after the trigger, 25 seconds after your timeout: 45_000 gave up. Background job queues, provider throttling, and a 452-retry from branch A6 all produce this. Evidence: the message's received_at versus your test's failure timestamp; if received_at is after the failure, the budget lost the race. Fix: raise the SDK timeout — it's a ceiling, not a duration; the wait still resolves the moment the mail lands, so a generous budget costs nothing on the happy path. Then check the layer above: your test runner's own timeout must exceed the SDK deadline, or the runner kills the test before you ever see the MailFixtureTimeout. (JS timeouts are milliseconds, Python's are seconds — timeout=30_000 in Python is eight hours.) A single long-poll request holds up to 60 seconds server-side; the SDKs chain requests under your deadline, so any timeout value just works — the messages concept page has the full wait/since/match semantics.
The failure-modes table
inbox.emailAddress exactlyttl_expires_at in the past; bounce timestamped after itTTL 10–15 min; delete in teardown insteadmatch too strict / wrong fieldList with your exact match → message disappearsLoosen the substring; check from_addr; mind the bare-term-is-subject rulesince built from a fast local clockreceived_at earlier than your cursorCursor only from wire received_atreceived_at predates the triggerInbox per test; clear() before resendreceived_at later than the test's failure timeRaise the SDK timeout; runner timeout above it; JS ms vs Python sA diagnostic you can paste
When a suite starts flaking on email, drop this into the failing test's catch block before anything else — it runs the fork check for you:
import { MailFixtureTimeout } from "mailfixture"; try { const otp = await inbox.waitForOtp({ timeout: 30_000 }); } catch (err) { if (err instanceof MailFixtureTimeout) { // Fork check: what does the inbox ACTUALLY hold? const all = await inbox.messages(); // no match, no since console.error( all.length === 0 ? `Branch A: nothing arrived at ${inbox.emailAddress} — check app logs + provider bounces` : `Branch B: ${all.length} message(s) present — first: ` + `from=${all[0].fromAddr} subject=${JSON.stringify(all[0].subject)} received=${all[0].receivedAt}`, ); } throw err; }
from mailfixture import MailFixtureTimeout try: otp = inbox.wait_for_otp(timeout=30) except MailFixtureTimeout: all_msgs = inbox.messages() # no match, no since if not all_msgs: print(f"Branch A: nothing arrived at {inbox.email_address}") else: m = all_msgs[0] print(f"Branch B: {len(all_msgs)} present; from={m.from_addr} " f"subject={m.subject!r} received={m.received_at}") raise
One console.error line converts "it timed out" into "it's branch B1" — and the from_addr/subject/received_at it prints are exactly the fields the table's evidence column asks for.
Edge cases that imitate a missing email
- Retention expiry. Messages are deleted after your plan's retention window (3 days on free, 14–30 on paid). Reopen Monday's failure on Thursday and the inbox is empty — the mail arrived, then aged out. Diagnose from the run's own artifacts, not from what's still in the inbox days later.
- Parallel workers on one inbox. Two workers polling one shared inbox each consume the other's message — one passes with the wrong data, the other times out. It presents as a random missing email; it's actually a race. Inbox-per-test dissolves it — the parallel guide reproduces it.
- Inbox-creation velocity. Creation bursts to 10 per key, refilling at 10/minute. A heavily parallel suite creating faster than that gets 429s at creation time — before any email is in flight — which surfaces as setup failures misattributed to email.
What this tree can't see
MailFixture observes exactly one thing: mail that reached its receiving edge. It's receive-only — it never sends, so it cannot retry, resend, or inspect your provider's side of the conversation. For every Branch A cause, the authoritative evidence is in your application logs and your provider's dashboard; MailFixture can only tell you, definitively, that nothing arrived — and, via the bounce your provider records, how a 550 or 452 was answered. And an email arriving in a test inbox proves delivery of your flow; it says nothing about spam placement or rendering in real mail clients.
Where to go next
Most branch-B bugs disappear structurally with an inbox-per-test setup and bounded waits — the pattern walkthroughs cover it per framework:
- The flake anatomy behind most of Branch B, in depth: testing OTP email flows.
- The inbox-per-test fixture, ready to paste: Playwright or pytest.
- The reference semantics behind the table — TTL lifecycle and 550s in inboxes;
wait,since, andmatchin messages.