DEV Community

DapperX
DapperX

Posted on

Run-Scoped Email Fixtures for CI Automation

Email checks are often the least deterministic step in an otherwise tidy CI pipeline. A test creates a user, waits for a verification message, follows a link, and then reports success. When it fails, someone says the email was slow.

That explanation is usually too small. The message may belong to a previous run, the application may never have emitted an event, or the poller may have found a message but cannot prove it was the right one. A throwaway email address can isolate traffic, but isolation alone does not give the test ownership, timing, or cleanup rules.

A run-scoped email fixture is a small automation contract around that mailbox. It gives every test run an identity, an expected message, a bounded wait, and a final receipt. The idea is simple enough for a local script and useful enough for a serious CI workflow.

Why shared inboxes make CI automation ambiguous

Shared inboxes are convenient at first. The test team gets one address, the application sends messages there, and a worker searches for the newest item. The inbox are shared, though, so “newest” is not the same as “mine.” Parallel jobs can also create messages with the same subject.

This creates a few misleading outcomes:

  • A retry consumes a message from the first attempt and looks successful.
  • A delayed message arrives after the test has already failed.
  • A cleanup job deletes evidence that would have explained the failure.
  • A test passes locally but races with another branch in CI.

The fix is not necessarily a larger email platform. Start by making ownership explicit. A test should be able to answer which run reserved the fixture, which message it expected, and why the final state was accepted.

Define a run-scoped email fixture

Give each run a unique ID and bind the fixture to it. The ID can be placed in test-only metadata, a safe correlation header, or a unique recipient address when the mail system supports it. Do not put secrets or full verification tokens into a subject line just to simplify debugging.

Here is a deliberately small contract:

{
  "run_id": "signup-2026-11-11-8f2a",
  "scenario": "new-user-verification",
  "mailbox_id": "ci-8f2a",
  "expected_kind": "verification",
  "created_at": "2026-11-11T11:21:00Z",
  "expires_at": "2026-11-11T11:26:00Z"
}
Enter fullscreen mode Exit fullscreen mode

The fixture should have one owner and one expiry. If the job crashes, another process can identify the record as stale and reclaim it. If the mailbox provider does not support leases, store the ownership record in the test system and make the message matcher enforce the same run ID.

For the UI side of the flow, clear state names matter too. The pattern in email verification states that explain themselves is a useful reminder that “waiting,” “received,” and “invalid” should not collapse into one generic spinner.

Create a small fixture contract

The fixture API only needs a few operations:

  1. reserve(run_id, scenario) creates or leases an isolated mailbox.
  2. send_trigger(run_id) starts the application action under test.
  3. wait_for_message(run_id, expected_kind, deadline) observes matching messages.
  4. release(run_id) marks the fixture for cleanup.

Keep these operations separate from assertions. A test can then distinguish “the application did not create an event” from “the email provider did not deliver it.” That separation makes failures less mysterious and keeps retries targeted.

The fixture record can also carry a cursor, such as the last message ID observed. This prevents a restart from scanning the whole inbox and guessing which item is current. It is a small Developer Tools decision, but it removes a surprising amount of noise from logs.

Some search notes contain phrases like temp org mail or tempail mail. Those phrases may describe what somebody typed while exploring a workflow, but they should never become an implicit test policy. The fixture contract is the policy: owner, scenario, deadline, and evidence.

Poll with evidence instead of hope

Polling should have both a deadline and a reason for retrying. A simple loop can record the last cursor, the number of polls, and the latest observed state:

async function waitForMessage(readInbox, matches, deadlineMs) {
  const startedAt = Date.now();
  let cursor = null;
  let polls = 0;

  while (Date.now() - startedAt < deadlineMs) {
    const page = await readInbox({ cursor });
    polls += 1;
    cursor = page.nextCursor;

    const message = page.messages.find(matches);
    if (message) {
      return { status: "received", messageId: message.id, polls };
    }

    await new Promise((resolve) => setTimeout(resolve, 500));
  }

  return { status: "timeout", polls, cursor };
}
Enter fullscreen mode Exit fullscreen mode

The example is intentionally boring. Its easy to add more retries and make a test look tougher, but an unbounded poll only hides the real boundary. Record whether the application event existed, whether delivery was observed, and whether the message matched the current run. Those three signals tell you much more than a generic timeout.

If the email flow is backed by an API, the same thinking applies to duplicate triggers. Idempotent email verification APIs show why a retry needs a stable request identity instead of creating a second ambiguous message.

Clean up without hiding failures

Cleanup should run after both success and failure, but it should not erase the receipt immediately. Keep a small record with the run ID, final state, message ID if one matched, poll count, and timestamps. The message body and tokens usually do not belong in a long-lived CI artifact.

If cleanup fails, mark it as a separate failure. Do not rewrite a passed test into a vague cleanup error, and do not quietly delete a fixture that another diagnostic step still owns. A short retention window is often enough for investigation. The fixture need not live forever to be useful.

A reaper can find expired records later. This make crash recovery predictable: the job does its best cleanup, and a scheduled process handles the cases where the runner disappeared before release. Keep the reaper idempotent so running it twice has the same result.

A practical checklist

Before calling the workflow reliable, check that it can:

  • create a unique run ID for every attempt;
  • reserve or prove ownership of one fixture;
  • match messages to the current run, not only to a subject;
  • use a bounded polling deadline;
  • record useful negative evidence;
  • keep tokens and message bodies out of default logs;
  • release the fixture after success and failure;
  • reaper stale fixtures without changing a test result.

The best automation contracts are small enough to understand during an incident. Start with one run ID, one mailbox lease, one cursor, and one receipt. Add more machinery only when a real failure shows which boundary is missing. That keeps CI friendly to builders and a bit more honest when the email step dont behave as expected.

Top comments (0)