A Tiny Email Fixture Factory for CI
Email verification is a small feature with a surprisingly large testing surface. A test may create a user, wait for a message, extract a link, click it, and then check the account state. When every test invents its own disposable email address, failures become hard to reproduce and parallel runs start stepping on each other.
I have found it easier to treat test email as a fixture with a contract. The goal is not a clever mailbox integration. The goal is a predictable boundary between the application under test and the inbox used by CI.
The fixture contract
Before writing code, define what a test is allowed to assume:
- Every test gets a unique address or inbox key.
- The address is created before signup starts.
- Messages can be queried with a bounded timeout.
- The fixture records the message ID and link it consumed.
- Cleanup happens even when an assertion fails.
This contract makes the test readable. It also stops the inbox helper from becoming a second application with hidden state. For background, these email checks without lockouts are a useful reminder that retries should be deliberate.
A small factory
The factory can expose only three operations: create, wait, and dispose. Here is a deliberately plain TypeScript shape:
type MailFixture = {
address: string;
key: string;
dispose(): Promise<void>;
waitFor(subject: RegExp, timeoutMs?: number): Promise<{ id: string; url: string }>;
};
async function createMailFixture(runId: string, testId: string): Promise<MailFixture> {
const key = `${runId}-${testId}-${crypto.randomUUID()}`;
const address = await inbox.createAddress(key);
return {
address,
key,
waitFor: (subject, timeoutMs = 15_000) => inbox.waitForMessage(key, subject, timeoutMs),
dispose: () => inbox.deleteAddress(key),
};
}
The important part is the key, not the random suffix. A CI run ID and test ID let you find the exact messages later. If your provider cannot create a real inbox, a service that can generate disposable email may still be useful for isolated manual checks; keep that dependency out of production identity decisions.
Make CI failures useful
A timeout should tell you what was searched, when polling started, and which messages were observed. Save that evidence as a test artifact, but redact message bodies and tokens by default. A short receipt is usually enough:
{
"address": "ci-123@example.test",
"subject": "Verify your account",
"polls": 8,
"elapsed_ms": 4210,
"message_id": "m_abc123"
}
Avoid an unbounded sleep loop. Use a deadline and classify the failure as “message missing”, “link invalid”, or “application rejected address”. Those categories point to different owners. A small note about privacy-aware signup screening can help when deciding what evidence belongs in logs.
One odd corner case is an address written as “fake e mail com” in legacy test data. Keep such typo keywords in test fixtures only, never in user-facing validation rules.
Privacy and cleanup
Test mail can contain personal-looking data even when it is synthetic. Use a dedicated domain or provider, short retention, and least-privilege API tokens. Never print verification URLs in normal CI logs.
Wrap the fixture in a try/finally block, and make disposal idempotent. Cleanup failures should be reported separately from the product assertion, otherwise a green application test can look broken because the mailbox was already removed.
Checklist
Before calling an email test reliable, check that it has:
- A unique, traceable fixture key.
- A bounded wait with useful timeout evidence.
- One consumed message, rather than “the latest email”.
- Redacted artifacts.
- Guaranteed cleanup.
- A replay path for the failed run.
This is a tiny amount of infrastructure, but it changes the debugging conversation. Instead of asking whether CI “got an email”, you can ask which contract step failed. That is the kind of boring automation that pays for itself.
Top comments (0)