An email assertion is usually the flakiest step in a suite: the address is
shared, the wait is a sleep, and a stale message from the last run passes a
test that should have failed. MailMatter is built so none of those are
necessary. This page collects the practices that keep email tests
deterministic.
Prerequisites: an inbox and its credential — an anonymous secret from the
quickstart, or an org API key (mm_…) from
Settings → API keys.
Give every test case its own address
Scenarios are implicit: {handle}.{anything}@mailmatter.dev starts working
the moment something sends to it, with no create call and no cleanup
obligation. So spend them freely — one per test case, rather than one shared
inbox everything reads from:
const address = (scenario) => `${handle}.${scenario}@mailmatter.dev`;
address("signup"); // signup happy path
address("signup.expired-token"); // the failure case
address("password-reset");A test that owns its address can never read another test’s mail, which removes an entire class of cross-test flakes before you write any assertion.
Wait for the email
GET /api/mailbox/{handle}.{scenario}/wait blocks until a message strictly
newer than a since watermark arrives, then returns it fully parsed. One
call replaces a sleep-and-retry loop, and the watermark makes it impossible
to match a stale message from an earlier run.
Capture the watermark before triggering the send
The order is the whole idiom: take the timestamp first, then cause the email. Anything that arrives after the watermark — and only that — can satisfy the wait.
const since = Date.now();
await page.getByRole("button", { name: "Sign up" }).click(); // triggers the emailBlock on the wait endpoint
const res = await fetch(
`https://mailmatter.dev/api/mailbox/${handle}.signup/wait?since=${since}&timeout=55`,
{ headers: { Authorization: `Bearer ${secret}` } },
);
const { message } = await res.json();timeoutis in seconds, maximum 55. On timeout the call returns200with"message": null— a normal outcome your test should treat as “the email didn’t come”, not as a transport error.- The endpoint is rate-limited per IP; one blocked call per arriving email is the intended usage, not a tight retry loop.
- Set the wait timeout a little under your test runner’s own timeout, so a missing email fails with your assertion message rather than the runner’s.
Assert on the parsed message
Assert on the data, not on a rendered view. text is the decoded plain-text
body and links the verbatim extracted href values — nothing has been
rewritten, resolved, or visited:
expect(message).not.toBeNull();
const otp = message.text.match(/\b(\d{6})\b/)[1];
const verifyLink = message.links.find((l) => l.includes("/verify"));Because MailMatter never follows a link on your behalf, a one-time token is
still unspent when your test gets it — see
The hermetic inbox. For header-level assertions
(DKIM, custom X-* metadata) read headers, or fetch
GET /api/message/{id}/raw for the byte-exact .eml.
For a complete Playwright OTP example, see Test email OTP flows in Playwright.
Keep parallel runs apart
Two options, freely combined:
- A scenario per run. Scenarios need no setup, so
run-${CI_RUN_ID}.signupgives every pipeline its own inbox for free. - A header filter per run. If your mailer can set a header such as
X-Test-Run: 42, list withGET /api/mailbox/{handle}.{scenario}?header=X-Test-Run:42and assert only on tagged messages.
Clean up after the run
curl -s -X DELETE https://mailmatter.dev/api/mailbox/<handle>.signup \
-H "Authorization: Bearer <secret>"Deletion is optional — every message expires after your plan’s retention
window anyway — but purging in an afterAll keeps a long-lived handle’s
listings readable and its volume down.
Troubleshooting
A test that times out is almost always one of these:
- The address is wrong. It must be
{handle}.{scenario}@mailmatter.devexactly; mail is lowercased and split on the first dot. - The watermark was taken after the send. The message arrived before
since, so the wait ignores it. Capture it first. 403— the credential doesn’t match the handle.404— the handle doesn’t exist (check for a typo, or that it hasn’t been released).402— the message did arrive but is over your plan’s volume cap, so only its envelope is returned. That’s an inbox-full signal, not a missing email.
Next steps
- REST API — the full message shape and every parameter.
- Connect an agent over MCP — the same idioms as agent tools, no HTTP glue.
- Deliver mail to a webhook — push delivery when polling is the wrong shape entirely.