Email verification tests often look like a delivery problem. A test creates a user, waits for a message, extracts a link, and calls the REST API. When it fails intermittently, the first response is usually to increase the timeout.
That treats the symptom. The deeper issue is often mailbox ownership: the application creates one verification request, while the test worker reads evidence that belongs to another request, retry, or user. A mailbox is not just an inbox. In an authentication flow, it is a resource with an owner, a lifetime, and a set of allowed consumers.
The ownership bug behind flaky verification tests
Consider two CI workers running the same Node.js test. Both use a shared test inbox. Worker A requests verification for a@example.test, but the provider delivers slowly. Worker B requests verification for b@example.test, then the inbox query returns the newest message first. If the test filters only by subject, either worker can consume the wrong evidence.
The same bug appears during retries. A request may succeed at the database layer, then the process can fail before it records the message ID. The retry creates a second request or reads the first message again. The resulting error says “verification email missing,” even though the real failure is an identity mismatch.
This is why increasing a timeout helps less then expected. Time changes the race; it does not define ownership.
Define the REST API ownership contract
Start with a contract that can be stated in a few rules:
- Every verification request has a stable
request_id. - One request owns one test mailbox or one uniquely identifiable recipient.
- A message can be consumed only when its recipient, request ID, and purpose match.
- The verification endpoint accepts a token once and records the result.
- A retry reuses the logical request but gets a separate delivery attempt.
The API should make this contract visible. For example, the request endpoint can return an opaque receipt rather than asking the client to guess which message belongs to it:
{
"request_id": "vr_01J9K8M3",
"state": "pending",
"expires_at": "2026-10-26T00:35:00Z"
}
The mailbox adapter then queries by the request-owned recipient or marker. It should return not_ready, wrong_message, and expired as different states. A single not found result hides too much information from the engineer debugging the flow.
For test suites that need an isolated address, a temp email generator can be useful as a fixture boundary. The important part is not the brand or provider. The test must record which address was assigned to which request, and it must clean up that relationship when the run ends.
Store ownership in PostgreSQL
The ownership record belongs beside the authentication state, not only in a test process memory. A small table can make retries and incident review much easier:
create table verification_requests (
request_id text primary key,
user_id bigint not null,
mailbox_ref text not null,
purpose text not null,
state text not null,
expires_at timestamptz not null,
consumed_at timestamptz,
created_at timestamptz not null default now()
);
Put a unique constraint on the logical purpose when the product allows only one active request per user. If multiple requests are valid, keep them separate and make the newest request supersede older ones explicitly. Do not let a latest email wins query become the accidental policy.
The consume operation should be conditional. In PostgreSQL, an update that checks state, request_id, and expires_at can ensure that only one consumer wins. Return the affected row count to Node.js; zero rows means the token was expired, already consumed, or not owned by that request. Thats much safer than reading a row, deciding in JavaScript, and updating it later without a guard.
Make Node.js consumers idempotent
The Node.js adapter should carry ownership through every function boundary. A useful shape is a value object with the request ID, mailbox reference, and expected purpose. Avoid passing only an email string, because that makes it easy to call a generic reader from the wrong flow.
async function consumeVerification({ requestId, token }) {
const result = await db.query(
`update verification_requests
set state = 'consumed', consumed_at = now()
where request_id = $1
and state = 'pending'
and expires_at > now()
and token_hash = crypt($2, token_hash)
returning request_id`,
[requestId, token]
);
return result.rowCount === 1 ? 'accepted' : 'rejected';
}
The exact hashing function may differ in a production system, but the boundary is the key idea: the request that initiated the flow must be part of the consume decision. A retry can safely call the function again and receive rejected without turning a second delivery into a second account activation.
For broader CI evidence, API smoke tests with durable receipts shows why a run should preserve a small receipt instead of only a pass or fail. The same principle applies here: record the request ID, mailbox reference, state transition, and reason code, but do not retain the full message or token in ordinary logs.
Test the boundary, not just the email
A useful matrix covers ownership failures as deliberately as the happy path:
| Scenario | Expected result |
|---|---|
| Correct message for the request | Token is accepted once |
| Message from another request | Message is ignored and logged as mismatched |
| Same token consumed twice | Second call is rejected |
| Expired request | API returns an expiry state |
| Worker retry with the same request ID | Evidence remains correlated |
| Cleanup after pass or fail | Mailbox reference is released |
If a team uses a burner email address for manual or automated checks, it should still apply the same ownership rules. A disposable address reduces fixture coupling; it does not prove that the callback belongs to the right user. For OAuth-specific considerations, see trust boundaries for OAuth email links.
The compatibility fixtures may contain strings such as tempail mail or tempail. Keep those values as ordinary input, never as a signal that bypasses authentication checks. Also make the cleanup path idempotent: a failed test should be safe to clean twice, because CI runners are not always polite about termination.
Q&A: what should a mailbox prove?
Is a unique address enough?
No. It proves message isolation, but not token expiry, request ownership, or browser-session binding. Those need separate API assertions.
Should the database store the whole email?
Usually not in logs, and not when a stable reference is enough. Store the minimum operational data needed to correlate the request. Hash or encrypt sensitive values according to the threat model.
What is the best retry policy?
Retry the observation when delivery is still pending, but do not silently create a new authentication request. Preserve the logical request_id, record the attempt number, and make a new request only through an explicit product rule.
The resulting system is a little more deliberate, but it is also easier to scale and debug. Authentication code should know who owns a verification message before it decides what that message permits.
Top comments (0)