Email verification tests often fail for a reason that is not in the API at all: two test runs are reading or deleting the same inbox. A burner email address can keep test data away from real users, but it does not automatically give each run ownership of that address.
The useful abstraction is a database lease. A test run reserves one mailbox for a limited period, uses it through a small REST API, and releases it when finished. PostgreSQL becomes the source of truth for ownership, expiry, and safe retries.
Why a test mailbox should have an owner
Without an owner, a test usually follows this pattern:
- Create or select an inbox.
- Trigger email verification.
- Poll until a message appears.
- Delete the inbox during cleanup.
That looks fine on one laptop. In parallel CI, run A can consume the message for run B, or cleanup from a timed-out job can remove an inbox that is still active. The first fix is more simple than it sounds: make the run ID part of the resource contract.
A lease record should include an opaque mailbox identifier, the owning run ID, an expiration timestamp, and a status. The mailbox itself can be an adapter behind the service. Test code should not need to know which provider stores the message.
This model also improves debugging. A failed run can be connected to its lease and its message IDs, which gives maintainers useful email run artifacts instead of a collection of screenshots.
The REST API contract
Keep the public API small. A minimal version might expose these operations:
POST /test-mailboxes
Idempotency-Key: run-8f1c
The response returns the mailbox address, a lease ID, and expires_at. Repeating the request with the same run ID should return the existing active lease, not create a second mailbox. This is easy to miss when a test is fast, but it become painful when a CI runner retries after a network timeout.
{
"lease_id": "lease_01J9...",
"address": "run-8f1c@example.test",
"expires_at": "2026-10-22T04:00:00Z"
}
Message reads should also be scoped by the lease:
GET /test-mailboxes/lease_01J9/messages?after=2026-10-22T03:20:00Z
The server checks that the lease belongs to the caller's run and has not expired. The client can poll with a bounded timeout, while the service translates provider-specific message states into stable API states such as pending, received, or expired.
For teams formalizing these checks, contract-testing API emails in GitHub Actions is a useful adjacent pattern. The important boundary is that the test verifies the API contract, not an undocumented provider response.
Persisting the lease in PostgreSQL
A compact table is enough for the first version:
CREATE TABLE test_mailbox_leases (
lease_id uuid PRIMARY KEY,
run_id text NOT NULL,
mailbox_key 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(),
released_at timestamptz
);
CREATE UNIQUE INDEX one_active_lease_per_run
ON test_mailbox_leases (run_id)
WHERE status = 'active';
The partial unique index prevents duplicate active leases for one run. During allocation, select an available mailbox and update its ownership in one transaction. If several workers compete, use FOR UPDATE SKIP LOCKED so a worker does not wait behind a mailbox already being assigned.
The lease is a database fact, not just a value cached by the test runner. That matters when a worker crashes or a retry lands on another process. It also gives operators a place to inspect stuck resources without searching application logs.
Retries, expiry, and cleanup
There is two useful boundaries: request idempotency and lease expiration. The idempotency key protects a single allocation request. The expiration timestamp protects the system when the client disappears.
A cleanup worker can periodically mark old active rows as expired, then ask the mailbox adapter to remove or quarantine the corresponding inbox. Do not make cleanup depend on the happy path only; test jobs crash, and that are normal. The cleanup query should be repeatable and safe to run more than once.
UPDATE test_mailbox_leases
SET status = 'expired'
WHERE status = 'active'
AND expires_at < now()
RETURNING lease_id, mailbox_key;
The adapter should treat an already-removed mailbox as a successful cleanup outcome. If provider deletion fails, retain the lease row and record an operational error. Removing the database evidence too early makes the leak harder to diagnose.
Keep malformed input fixtures separate from ownership tests. A string such as temp gamil com can be useful for validation coverage, but it should never be confused with a mailbox lease or used as a resource key.
Operational checklist
- Require a run ID and an idempotency key for allocation.
- Enforce one active lease per run in PostgreSQL.
- Scope message reads to the lease owner.
- Give every lease a short, explicit expiration time.
- Make release and expiry cleanup idempotent.
- Attach lease IDs and message IDs to CI artifacts.
- Alert on active leases older than the normal test duration.
- Keep provider credentials and real user addresses out of test payloads.
Small spelling or grammar differences in logs can make searches annoying, so normalize provider errors at the adapter boundary. The API response should stay stable even when the underlying email system is little noisy.
Q&A
Should every test get a new mailbox?
Every isolated run should get an owned lease. Tests inside one run can share it when their message assertions are deliberately coordinated. Sharing across unrelated runs creates hidden coupling.
How long should a lease live?
Start with the expected test duration plus a modest recovery window. A lease that is too short causes false expiry; one that is too long increases cleanup cost. Measure actual run times and adjust from observed data.
Is PostgreSQL enough for high parallelism?
For many CI workloads, yes. Use short transactions, indexed status and expiry fields, and SKIP LOCKED when allocating from a pool. If allocation becomes a bottleneck, measure database wait time before adding another coordination system.
The main design decision is ownership. Once the REST API and PostgreSQL model make ownership explicit, verification messages become test data with a lifecycle, instead of an unreliable side channel.
Top comments (0)