DEV Community

Silviu Technology
Silviu Technology

Posted on

Stop Cross-Test Inbox Pollution

One of the easiest ways to create flaky end-to-end tests is to let multiple scenarios read from the same mailbox without strict ownership rules. The browser flow may be correct, the API may be fine, and the email still gets matched by the wrong test. When that happens, teams often blame the provider first, but the bug is usualy in the test contract.

I hit this pattern most often in signup, password reset, and invite flows. A suite passes locally, then fails in CI because one worker sees a message that belonged to another worker two seconds earlier. If your setup uses a disposable mail address for speed, the risk gets bigger unless the test is careful about who owns each message and when that ownership starts.

Why shared inboxes break otherwise good tests

Shared inboxes are not automatically bad. They are just unforgiving. A test that asks only "did an email arrive?" is too loose for parallel automation, because the answer might be yes for the wrong reason.

Common failure modes look like this:

  • scenario A triggers a verification email
  • scenario B polls faster and claims it
  • a retry sees an old message and turns red into green
  • cleanup runs late, so the next build starts with leftover state

This is also why vague notes like tem email make triage slower. The note tells me a mailbox was involved, but not which scenario, which trigger, or which timing window. For QA, the missing context matters more than the failure itself.

Give each scenario an ownership rule

The fix is boring in a good way: every scenario gets a small ownership record before inbox polling starts. I do not mean a huge object or a fancy abstraction. Just enough data to prove that one test owns one expected message.

My default ownership record includes:

  • scenario id
  • worker id
  • normalized recipient
  • expected template or subject fragment
  • trigger timestamp

That tiny record creates a boundary. Once you have it, your poller can reject any message that arrived before the action fired or that does not belong to the current scenario. It works nicely with email failure tracing in Playwright, because the browser trace and inbox evidence now point to the same run.

Poll for evidence, not just any message

The main mistake I still see is returning true as soon as any matching subject appears. A better pattern is to return evidence. That means the helper should report what it searched, how many messages it saw, and why one message won.

In practice, I want the poll result to answer these questions:

  • Which recipient was queried?
  • Which scenario id owned the poll?
  • Did the matched email arrive after the trigger?
  • Was there more than one plausible candidate?

If the test team already has reusable email check workflows, this ownership check becomes a pretty clean extension instead of a rewrite.

Here is the shape I like:

type InboxClaim = {
  scenarioId: string;
  workerId: number;
  recipient: string;
  subjectIncludes: string;
  triggeredAt: number;
};

async function claimMessage(claim: InboxClaim) {
  const messages = await inbox.poll({ recipient: claim.recipient, timeoutMs: 15000 });

  const match = messages.find((message) => {
    return message.subject.includes(claim.subjectIncludes)
      && Date.parse(message.receivedAt) >= claim.triggeredAt
      && message.headers["x-scenario-id"] === claim.scenarioId;
  });

  return {
    claim,
    inboxCount: messages.length,
    matchedId: match?.id ?? null,
    matchedAt: match?.receivedAt ?? null
  };
}
Enter fullscreen mode Exit fullscreen mode

That helper is not magical, and that's kind of the point. It is small enough to trust, easy enough to inspect, and honest when no message can be claimed.

A Playwright helper that stays debuggable

I prefer wiring the ownership record directly into the test step that triggers the email. That keeps the timeline obvious:

  1. create the scenario id
  2. perform the UI action
  3. save the trigger timestamp
  4. poll with ownership filters
  5. attach the claim result to the test output

When the run fails, I want a JSON artifact right next to the Playwright trace. If the inbox result shows three candidates and none have the right header, the issue is suddenly not mysterious anymore. It is just a bug with evidence, which is a much better place to be.

This also helps when someone says "maybe tepm mail com was slow again." Sometimes the provider really is slow, sure. But plenty of cases are self-inflicted: broad subject matching, no scenario id, or cleanup that happened after the next worker had already started. The diagnosic signal gets way better once the helper records ownership explicitly.

Checklist before blaming the mail provider

Before I increase timeouts or swap providers, I check these five things:

  • Does each scenario write its ownership record before polling?
  • Is the message filtered by recipient and trigger time?
  • Can the app expose a scenario id or event id in headers or payload metadata?
  • Does cleanup run after assertion artifacts are saved?
  • Can a failing run show why a candidate was rejected?

If the answer is no to two or three of those, I would not trust the test yet. Fast mailboxes are useful, but they cannot rescue a weak contract.

Q&A

Do I need a fresh inbox per test?

Not always. A fresh inbox is the easiest isolation model, but it is not the only one. Shared inboxes can work if scenario ownership is strict and recorded.

What if my provider does not let me add custom headers?

Use a subject token, request id, or server event id instead. The exact field matters less than proving that the message belongs to this scenario and not another one.

Is this too much ceremony for small teams?

I do not think so. The helper is small, and the debugging payoff is real. Once you stop treating inbox polling as a yes-or-no check, flaky email tests get a lot less weird, and your CI feels more human.

Top comments (0)