Email verification tests often fail for a reason that is not really email. A test creates an account, requests a verification message, waits for an inbox, and then asserts that a link arrived. If the inbox is shared or its lifetime is undefined, the test has no reliable answer to a simple question: does this message belong to this run?
I treat a test inbox as a leased resource. The lease has an owner, an expiration time, and a state. That small model makes a REST API easier to retry and gives PostgreSQL enough information to reject stale or competing work. It also keeps temporary disposable mail in its proper place: a controlled fixture for a test environment, not a substitute for an application's identity model.
Why an inbox should have a lease
A mailbox address by itself is not a test fixture. It is only a location. Without ownership and expiry, several jobs can read the same verification email, or a new job can consume a message left by an older one.
The usual workaround is to add a longer sleep. That can hide the race for a while, but it does not identify the message or prevent a different job from reading it. A lease creates a boundary around the resource:
-
lease_ididentifies one reservation. -
run_idconnects the reservation to the test execution. -
addressis the mailbox location, ideally generated for this run. -
statusmoves throughactive,released, orexpired. -
expires_attells readers when the reservation is no longer valid.
The boundary matters most when a CI worker is interrupted. The lease can expire without requiring a cleanup process to run perfectly. That is a more dependable contract than assuming every job reaches its finalizer.
Separate verification intent from mailbox ownership
The verification request and the mailbox lease should be related, but they should not be the same row. A verification intent answers, “which account needs a token?” A lease answers, “which test run may inspect this inbox?”
This separation also makes the API states clearer. The signup endpoint may return 202 Accepted after creating an intent. A worker sends the message. A test reader later records evidence that a matching message was observed. The explicit email states in signup flows are useful here because the UI and the test can distinguish pending, verified, expired, and failed instead of treating every non-success response as the same thing.
Do not put the verification token in a log line just to make the test easier to debug. Store a correlation ID and a hash of the expected message attributes. You can then prove that the subject, recipient, and request ID matched without making credentials or live links searchable in CI logs.
Implement the lease with PostgreSQL
A minimal schema can look like this:
CREATE TABLE email_test_leases (
lease_id uuid PRIMARY KEY,
run_id text NOT NULL,
address text NOT NULL,
status text NOT NULL CHECK (status IN ('active', 'released', 'expired')),
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX email_test_leases_active_idx
ON email_test_leases (address, expires_at)
WHERE status = 'active';
When allocating an address, use a transaction and lock the candidate row before changing its status. The allocation query should verify expires_at > now() and the caller's run_id. If the row is expired, mark it expired before reusing the address. That ordering is important; otherwise a slow reader may still look like the current owner.
The API that creates the lease should accept an idempotency key. A retry with the same key returns the original lease instead of creating a second reservation. This is where a REST API and the database cooperate: the endpoint owns the contract, while PostgreSQL enforces uniqueness and locking.
For larger suites, keep the lease table small by deleting old rows asynchronously. Do not delete active rows as part of routine cleanup. A cleanup task should first select only leases with expires_at < now() and then update them conditionally with status = 'active'. The condition prevents cleanup from releasing a lease that was renewed between selection and update.
Make REST API retries boring
Every operation in the flow should have a stable idempotency key: allocate, request verification, and record observation. A timeout after the server commits is then recoverable. The client repeats the request, and the server returns the existing result.
The response should expose state, not implementation detail. For example:
{
"lease_id": "6f7b3d2c-2d2d-4f91-9e77-6bd7d5d0a4bf",
"state": "active",
"expires_at": "2026-10-21T07:00:00Z"
}
A client must still handle 410 Gone after expiry. Retrying forever is not resilience; it is noise. The caller should create a new fixture and attach it to a new test attempt, while keeping the old lease ID in the failure record.
The same principle applies to reusable workflows for email API checks: make the setup and evidence steps repeatable, but do not silently reuse state from a previous run.
Use the inbox as evidence, not a timing oracle
Polling should query for evidence that matches the current run: lease ID when supported, otherwise a correlation value in the recipient alias, subject, or message header. A message that merely arrived recently is not enough.
For short-lived integration tests, a temp mail so inbox can provide that controlled boundary. Keep the provider call behind a small adapter so the application test does not depend on provider-specific polling details. In one test note, a developer might type temp mailid or tepm mail com while searching for a fixture; those phrases should never become production identifiers or query semantics.
When the provider cannot return a stable message ID, record the observation time, a normalized subject, and a digest of the relevant headers. The test can then explain why it accepted a message. It may sound slightly extra, but it save hours when a provider delivers messages out of order.
Operational checklist
- Give every test run a unique
run_idand every mailbox alease_id. - Enforce one active lease per address with a partial index or equivalent constraint.
- Use idempotency keys for allocation and verification requests.
- Reject expired leases before reading or releasing them.
- Match messages to run-specific evidence, not arrival time alone.
- Redact tokens, full addresses, and provider credentials from logs.
- Record the last provider message ID and polling error for diagnosis.
- Release leases on success, but rely on expiry for interrupted jobs.
This design turns email from an unpredictable external wait into a small state machine. The provider can still be slow, and a worker can still crash, but the API response, database row, and test evidence retain the same meaning after a retry.
Top comments (0)