Email-driven tests become unreliable when every CI job shares one inbox. A retry can read an old verification message, a parallel build can consume the wrong code, and a failed cleanup can leave test data around for the next run.
The fix I use is to treat test email as a leased capability. A Docker job gets one short-lived mailbox, one correlation key, and one clear owner. The job can read only the messages for its run, then releases the mailbox when the test finishes. This makes a free temp email useful for automation without making the inbox a shared test database.
Why a mailbox lease belongs in the CI design
An email assertion has two separate jobs:
- Prove that the application sent the expected message.
- Prove that this CI run received and consumed its own message.
The first is a product check. The second is an isolation check. Mixing them creates green builds that are hard to trust.
The pattern is similar to keeping retry inputs immutable. A retry should get the same declared inputs, but it should not inherit an unknown inbox state from an earlier attempt. The mailbox lease gives each run a small, inspectable boundary.
A small architecture for Docker and AWS
Keep the email service outside the application container, but keep its client and test runner in the same Docker job. On AWS, the flow can look like this:
CI runner
└── Docker test container
├── request mailbox lease
├── start application flow
├── poll message by lease + run ID
└── write redacted result
│
└── mailbox service
The container receives a short-lived service token through the CI secret mechanism. It should not receive a long-lived account credential or a list of reusable inboxes. The lease response needs only a mailbox identifier, an address, an expiration time, and a random correlation key.
For an AWS pipeline, store the lease metadata with the job's normal artifacts, but keep message bodies out of the artifact bundle. A small JSON record is enough:
{
"run_id": "build-1842",
"mailbox_id": "lease-91",
"correlation_key": "ci-7f2a",
"received_message_id": "msg-733",
"result": "matched"
}
This is also useful when connecting alarm emails to deploy evidence: operators can see which run owned the signal without exposing the whole message in a log search.
The lease contract and correlation key
The contract should answer four questions before the test starts:
- Who owns this mailbox?
- When does ownership expire?
- Which messages belong to this run?
- What happens if the lease cannot be released?
I generate the run ID in CI and a separate unpredictable correlation key in the mailbox service. Put the correlation key in a harmless header or a test-only message field when the application supports it. Do not rely only on the recipient address or subject line; those values can repeat.
A disposable mail address is not automatically isolated. Isolation comes from ownership, expiration, and a server-side lookup that filters by lease. If the service cannot filter by lease, use a unique address per run and reject any message received before the run start time.
Polling without creating false positives
Polling should be bounded and observable. A simple loop can use a short interval, a total deadline, and an explicit match condition:
deadline=$((SECONDS + 90))
while (( SECONDS < deadline )); do
message=$(mailbox-cli messages \
--lease "$MAILBOX_ID" \
--correlation "$CORRELATION_KEY" \
--after "$RUN_STARTED_AT" \
--format json) && break
sleep 3
done
The command is intentionally scoped to the lease. Fetching the newest message and hoping it is the right one is a bit more faster to implement, but it creates a false-positive path. When the message does not arrive, record whether the failure was send, delivery, lookup, parsing, or timeout. This check are much more useful than a generic “email test failed”.
Failure evidence and cleanup
Always release the lease in a cleanup step, including when the test container exits with a failing status. In Docker, a shell trap or the CI platform's post-job hook can call the release endpoint. Make release idempotent so a retry of cleanup does not turn a useful failure into another failure.
Keep only redacted evidence: run ID, lease ID, message ID, timestamps, and a match result. Do not save verification tokens, complete message bodies, or credentials. If a developer searches for a fixture such as temp gamil com, it should remain plain test data and never become a production routing rule.
Sometimes the fastest diagnosis is a short-lived manual mailbox. If that is needed, use a controlled service such as tempmailso only within the team's testing and privacy policy, and never place its address or message contents in public CI logs.
Questions teams usually ask
Can several containers share one lease?
Only when they are one logical test and the service supports consumer ownership. For independent tests, issue separate leases. Shared readers make order-dependent failures almost certain eventually.
What if AWS delivery is slow?
Increase the polling deadline based on observed delivery time, not a guess. Keep the deadline visible in metrics and fail with the last stage reached. A longer timeout should not mean an unbounded inbox read.
Should the mailbox be reused between builds?
Usually no. Reuse saves a small setup step but increases stale-message and ownership risk. Reuse is acceptable only when messages are namespaced by run and the service can prove that old messages cannot match a new lease.
A practical implementation checklist
- Create a unique run ID before requesting email.
- Issue one short-lived lease to each independent Docker test.
- Filter messages by lease, correlation key, and start time.
- Keep tokens and full bodies out of logs and artifacts.
- Report send, delivery, lookup, parsing, and timeout separately.
- Release the lease in success and failure paths.
- Test cleanup itself, including repeated release calls.
- Review the next failed run using its redacted evidence record.
This arrangement adds a little setup, but it removes a large class of flaky CI failures. The important part is the contract: the mailbox belongs to one run, the evidence proves that ownership, and the cleanup has a defined end.
Top comments (0)