GUIDES / DEBUGGING

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:

the two failure shapesJS + Python
// 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:

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:

the one decisive check
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:

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:

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

#SYMPTOMCAUSEOBSERVABLE EVIDENCEFIX
A1Timeout; inbox emptyApp never sentNo outbound message in provider dashboardFix the app/CI config; check queue workers
A2Timeout; inbox emptyWrong recipientProvider shows a send to a different address; account-wide recent feed shows it in another inboxCompare the recipient to inbox.emailAddress exactly
A3Timeout; inbox empty; app logs "sent"Provider suppression after an earlier hard bounceAddress on the provider's suppression listFresh inbox per run; clear the suppression entry
A4Timeout; inbox emptyUnknown address → SMTP 550Hard bounce in provider log; address absent from the dashboard inbox listFix the typo/domain; confirm the workspace
A5Timeout, then 404 on later pollsInbox TTL expired → 550, then sweptttl_expires_at in the past; bounce timestamped after itTTL 10–15 min; delete in teardown instead
A6Timeout, or message arrives very lateDaily cap (452, retried) or free monthly quota (550, permanent)Usage meter amber/red on the dashboard Overview; deferred or bounced entries at the providerWait for reset or upgrade; paid plans don't hard-stop monthly
A7Timeout; inbox emptyMessage over 10 MBProvider-side rejection during the SMTP sessionShrink the message
B1Timeout; message visible unfilteredmatch too strict / wrong fieldList with your exact match → message disappearsLoosen the substring; check from_addr; mind the bare-term-is-subject rule
B2Timeout; message visible unfilteredsince built from a fast local clockreceived_at earlier than your cursorCursor only from wire received_at
B3Wait resolves instantly with stale dataReused inbox / resend returned the first emailReturned received_at predates the triggerInbox per test; clear() before resend
B4Timeout; message arrives after failureSend latency exceeded the wait budgetreceived_at later than the test's failure timeRaise the SDK timeout; runner timeout above it; JS ms vs Python s

A 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:

fork check — JS/TS, around your existing waitnpm i -D mailfixture
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;
}
the same check in Pythonpip install mailfixture
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

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:

100 messages/mo free · the fork check is one unfiltered list call
Start free Read the quickstart