Why CI Email Tests Need a Canary Message
Email tests in CI often end with an unhelpful sentence: “verification email not received.” That result can mean the application failed to send, the provider delayed delivery, the test mailbox is unhealthy, or the poller is looking in the wrong place.
A small canary message makes this easier to diagnose. Before testing the real workflow, send a known message through the same inbox path and record whether it arrives. The canary does not prove that signup or password reset works. It tells you whether the test environment is capable of observing email at all.
Why an email test needs a canary
An email-driven check crosses several services: the application, a sender, a provider, a mailbox API, and the test runner. If the final assertion fails, all of those boundaries are suspects.
The canary adds a control measurement. It should be boring and deterministic:
- Allocate a mailbox owned by the current test run.
- Send a canary message with a unique subject.
- Wait for that message with the normal polling code.
- Only then execute the user-facing email flow.
If the canary fails, the result is probably an environment or inbox problem. If it passes but the verification message fails, the application path deserves attention. This simple split has saved me from increasing a timeout when the real issue was a misconfigured sender.
For short-lived test inboxes, tempmailso can be one option, provided the service fits the test data and retention requirements. The important part is the ownership contract, not the brand: each mailbox should belong to one run and have a clear expiration.
The two-path workflow
Think of the test as two related paths with different meanings:
allocate mailbox
|
+--> send canary --> observe canary --> inbox_ready
| |
| +--> fail: infrastructure or mailbox
|
+--> start product flow --> observe product email --> assert result
|
+--> fail: application or delivery path
The canary and product message should use different subjects and correlation IDs. Never select “the newest email” without checking ownership. A delayed message from a previous run can turn a flaky test into a false green.
An event record can stay compact:
{
"run_id": "ci-1842-7f2a",
"mailbox_id": "mbx-91",
"canary": {"status": "received", "message_id": "msg-1", "elapsed_ms": 820},
"product_email": {"status": "missing", "polls": 6},
"cleanup": "pending"
}
Do not save message bodies or verification tokens in ordinary CI artifacts. IDs, timestamps, and a redacted subject are usualy enough for triage. A developer can inspect the provider logs separately when deeper evidence is needed.
A small implementation
The same helper can power both checks. The only difference is the expected subject and the assertion that follows:
def wait_for_owned_message(inbox, subject, run_id, timeout_seconds=30):
deadline = clock.now() + timeout_seconds
while clock.now() < deadline:
for message in inbox.list_messages():
if message.run_id == run_id and message.subject == subject:
return message
sleep(1)
raise TimeoutError(f"message not found: {subject}")
canary = send_canary(run_id, inbox.address)
wait_for_owned_message(inbox, canary.subject, run_id)
request_verification(user, inbox.address)
verification = wait_for_owned_message(inbox, "Verify your account", run_id)
assert_token_matches_request(verification, request_id)
In a real implementation, include a finally block that releases the mailbox. If cleanup fails, record that as a seperate event instead of hiding the original test failure. The versioned reset tokens pattern is also a useful reminder that the token you observe should be tied to the request that created it.
Classify failures with evidence
Give the CI result a reason code instead of a generic timeout:
-
canary_missing: the inbox path did not provide its control message. -
product_missing: the canary worked, but the application message was absent. -
wrong_owner: a message arrived but belonged to another run. -
late_delivery: the expected message arrived after the test deadline. -
invalid_token: the message arrived, but its token did not match the request.
This makes dashboards useful. A rising canary_missing count points toward infrastructure. A rising product_missing count after healthy canaries points toward the application or sender configuration. The distinction is practicle, especially when several teams share one CI environment.
For browser-based checks, inbox snapshots for OTP tests show why preserving a small, redacted view of observed messages can help explain a failure without exposing the full inbox.
Keep mailbox ownership explicit
The test should create a unique run ID before allocating an inbox. Put that ID in the canary subject, message metadata when supported, and every event record. Expire the mailbox even when the test process crashes; a scheduled cleanup job is a good backstop.
Search terms such as temp mail mail, create temp mail, and temp mailid may appear in fixtures or support notes, but they should not decide which message is trusted. Ownership and correlation do that job.
Questions worth asking
Should every email test send a canary?
Not necessarily. Fast unit tests with a fake mail transport do not need one. Integration and end-to-end tests that depend on a real provider are the better fit.
Does a passing canary guarantee delivery?
No. It only confirms that the selected mailbox path worked for one controlled message. The product message still needs its own assertions.
What is the smallest useful artifact?
Keep the run ID, mailbox ID, reason code, message IDs, elapsed times, and cleanup status. That is usually enough to choose the next debugging step without retaining sensitive content.
A canary turns “email missing” into a more honest question: did the environment fail to observe mail, or did the product fail to produce the right mail? That is a small addition to an Automation workflow, but a meaningful upgrade for everyday Developer Tools and CI debugging.
Top comments (0)