Email automation usually looks simple in a happy-path diagram: create an address, trigger a message, read the inbox, and continue the job.
The difficult part starts when the message is late, the worker restarts, or two CI jobs are using similar test data. A retry can create a second account, consume the wrong verification email, or hide the original failure. The automation is still running, but nobody can say exactly what happened.
The fix is not a more complicated retry loop. It is a small state contract that tells every step what has happened, what may happen next, and which evidence belongs to the run.
Why retries need a state contract
A worker should not decide to retry because a generic exception occurred. It should retry because the current state is known to be temporary and the operation is safe to repeat.
For an email-driven workflow, useful states might be:
-
created: a test identity and run record exist. -
requested: the application asked for a verification or notification email. -
waiting: the message may still arrive inside its delivery budget. -
received: a message matching this run was found. -
verified: the next application action succeeded. -
expired: the address or token is no longer valid. -
failed: the run needs human investigation or a new attempt.
This is more precise than a single email_sent = true flag. “Sent” can mean that an API accepted a request; it does not mean that the expected message arrived, that it belongs to this run, or that the link still works.
The useful rule is simple: a retry may move a run forward from a known state, but it must not silently rewrite history.
Model the workflow as a lease
For short-lived automation, I like to treat the test mailbox as a lease. The run owns it for a limited period, and every message lookup includes the run's identity.
A minimal record can look like this:
{
"run_id": "ci-2026-10-24-1722-7f3a",
"mailbox_id": "qa-1842",
"state": "waiting",
"requested_at": "2026-10-24T17:22:15Z",
"expires_at": "2026-10-24T17:27:15Z",
"attempt": 2
}
The run_id is the important part. It should travel through the application request, the test data, the inbox query, and the CI logs. A subject suffix, metadata field, or unique recipient alias can carry it into the email itself.
This also explains when a disposable email address is appropriate. It is useful for an isolated test lease, not as a substitute for a production identity or a place to store sensitive customer data. A team may casually call it “temp org mail” or “fake e mail com” in a search note, but the implementation still needs a clear ownership and retention rule.
Keep each retry safe and observable
There are three questions to answer before adding another retry.
Can the operation be repeated?
Creating a new verification request might invalidate the previous token. If so, the retry should either reuse the original request or explicitly move the run to a new attempt. Do not let two valid-looking messages compete for the same test.
For token-based flows, an atomic token transition is a useful design boundary. This article on atomic tokens for email verification covers the database side of making one token win cleanly.
Can the reader identify the right message?
Matching only on a subject such as “Verify your email” is fragile. Filter by the run ID, recipient, sender, and a time window. Then record the message ID and the matching fields in the run evidence.
Parallel workers need an even stricter boundary. Isolating parallel email tests is a good companion pattern when several browsers are waiting for similar messages.
Can a developer explain the failure?
Every attempt should record a small event, for example:
waiting -> received | message_id=msg_91 | age=18s | attempt=2
Avoid dumping complete email bodies into ordinary CI logs. Store only the fields needed to diagnose the workflow, and keep the logs small enough that a developer can actually reads them during a failed build.
A small implementation pattern
The polling loop can stay boring. That is a feature.
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const message = await inbox.find({
runId,
recipient,
after: requestedAt,
});
if (message) {
await state.move("received", { messageId: message.id });
return message;
}
await sleep(2_000);
}
await state.move("failed", { reason: "delivery_timeout" });
throw new Error("verification email did not arrive inside the budget");
In production code, add cancellation and a maximum number of attempts. More importantly, make state.move validate allowed transitions. A failed run should not jump back to verified just because a late message appeared in the inbox.
Failure triage checklist
When a job fails, check these facts in order:
- Was the run ID generated once and reused for every request?
- Was the mailbox lease still active when the message was requested?
- Did the application accept the request, or did the worker fail before sending it?
- Did the inbox query filter by recipient, sender, and time window?
- Was the message found but rejected because its token was expired or already used?
- Did a retry create a second resource without recording the relationship?
This checklist make failures much less mysterious. It also turns a flaky test into a useful signal about delivery latency, application state, or cleanup.
Closing thought
Reliable email automation is mostly about boundaries: one run, one lease, one identity, and one explainable state history. The retry loop is the easy piece.
Start with a run ID and a small transition table. Add richer metrics later, after the basic evidence is trustworthy. The goal is not to make CI perfectly fast, it is to make every failure understandable enough to fix.
Top comments (0)