Email-driven workflows often look simple in a diagram: create a user, send a message, click a link, and mark the test as passed.
In a real CI run, the email can arrive late, a worker can retry, or a previous test can still own the inbox. The result is a flaky test with a vague error like “verification email not found.” A small state machine gives the workflow a memory and makes these failures much easier to reason about.
Why email automation needs explicit states
An email test is not only waiting for a message. It is coordinating an application, a mail delivery path, a test runner, and cleanup. Each part can move at a different speed.
Without explicit states, the test usually has a pile of booleans and sleeps:
created = true
sleep(5)
if message_exists():
click_link()
This can pass locally and fail under CI load. It also make retries difficult because the runner does not know whether it should wait, search, or release the mailbox.
The five-state model
For many staging and end-to-end tests, five states are enough:
- Reserved — the run has an isolated mailbox or mailbox lease.
- Triggered — the application accepted the action that should send an email.
- Observed — the expected message was found and matched to this run.
- Consumed — the test used the link, token, or code successfully.
- Released — the mailbox and related test data can be cleaned up.
Every transition should have an event ID, a timestamp, and a run ID. The logs is much more useful when it can answer “which run owns this message?” instead of only showing that a message arrived.
The Observed state is especially important. Finding any email with the right subject is not enough. Match a correlation value in the recipient, message header, link, or body. Otherwise, an earlier retry may be mistaken for the current test.
A small implementation contract
The contract can be represented by a record that is stored next to the test run:
{
"run_id": "checkout-1842",
"mailbox": "qa-1842@example.test",
"state": "triggered",
"expected_event": "verify-account",
"attempt": 1,
"expires_at": "2026-10-26T06:00:00Z"
}
The worker should accept the same run_id more than once. If a retry sees Observed, it should return the existing observation instead of creating another mailbox. This is the same mental model behind idempotent email verification APIs: repeat the request, but do not repeat the side effect.
Use bounded polling instead of one long sleep. For example, check after 1, 2, 4, and 8 seconds, then record a timeout with the last known state. The exact numbers depend on the environment, but the important bit is that each wait is visible in the run log.
Retries, ownership, and cleanup
Retries should be safe at every boundary. A delivery worker may send twice, and a test runner may ask twice. Store a message or event identifier when possible, and reject a result that belongs to another run.
For password reset and login flows, versioned reset tokens in PostgreSQL show a useful related pattern: make the valid version explicit, then invalidate older values. Email tests can apply the same idea to links and codes.
For a short-lived staging check, a throwaway email generator can provide a separate inbox boundary. Keep that inbox out of production data, give it an expiration time, and never treat it as a durable user identity.
Some failure reports contain phrases such as “temp gamil com” or “tempail mail” because a tester searched with a typo. Those phrases can be useful in support notes, but they should not become an identity key or a test assertion.
Cleanup should be a state transition, not an afterthought. If the test fails after Triggered, a finalizer can still release the mailbox. If the run is already expired, the cleanup job can remove it without trying to consume a message. This is less clever, but more easier to operate.
Practical checklist
- Give every email test a unique run ID.
- Store an explicit state instead of relying on sleep calls.
- Match messages to the current run, not only to a subject line.
- Make retries return existing observations where possible.
- Add expiration to mailbox leases, tokens, and test records.
- Log the transition that failed and the last observed state.
- Release test mailboxes even when the assertion fails.
The goal is not to build a large workflow engine. A small state machine is enough to turn email automation from a timing guess into a traceable developer tool. Once the states are visible, flaky tests become ordinary state transitions to inspect, retry, and improve.
Top comments (0)