DEV Community

kevindev
kevindev

Posted on

Lease-Based Email Fixtures for REST APIs

Email verification tests often fail for a reason that is not in the authentication code. A test creates a disposable email account, triggers a REST API request, waits for a message, and then tries to decide whether the message belongs to its run. When several jobs share a mailbox, the test is no longer checking one workflow. It is checking a race between workflows.

The practical fix is to treat a test inbox as a leased resource. A run owns it for a bounded period, the database records that ownership, and every message is correlated to an explicit verification attempt. This gives the dependancy a lifecycle that can be inspected and cleaned up.

Why email fixtures need ownership

An email verification test usually has four separate operations:

  1. Reserve an inbox fixture.
  2. Start verification through the application API.
  3. Find and validate the expected message.
  4. Release the fixture and preserve useful evidence.

Without an ownership boundary, a retry can read an old message, two workers can consume the same code, or a failed job can leave an inbox available to the next run. The fixture are then shared state disguised as a test helper.

Some older test names contain phrases such as tem email or tamp mail com. Those labels are not the design problem. The important questions are who owns the inbox, when that ownership expires, and how the service proves that a message belongs to the current attempt.

Define a lease and verification contract

Keep the contract small enough that every adapter can implement it. A lease record might look like this:

{
  "lease_id": "lease_01J9XK",
  "run_id": "orders-pr-1842",
  "mailbox_id": "mbx_73a1",
  "status": "active",
  "created_at": "2026-10-16T05:00:00Z",
  "expires_at": "2026-10-16T05:10:00Z"
}
Enter fullscreen mode Exit fullscreen mode

The verification attempt should have its own identifier. Do not use the recipient address as the only correlation key: an address may be reused after cleanup, and a single run can perform more than one verification. Store a request ID, expected purpose, and a message claim state such as pending, claimed, verified, or expired.

The API can expose this as a sequence of explicit operations:

POST /test-fixtures/mailboxes       -> create lease
POST /verification-attempts         -> start attempt
GET  /verification-attempts/{id}   -> read state
DELETE /test-fixtures/mailboxes/{id} -> release lease
Enter fullscreen mode Exit fullscreen mode

The delete operation should be safe to repeat. A cleanup call made after a timeout should not turn a successful earlier cleanup into an error that hides the original test failure.

Store ownership safely in PostgreSQL

PostgreSQL is useful here because lease ownership and verification state can be updated atomically. A minimal schema could use one row per lease and one row per attempt:

CREATE TABLE email_fixture_leases (
  lease_id text PRIMARY KEY,
  run_id text NOT NULL,
  mailbox_id 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 UNIQUE INDEX active_mailbox_lease
  ON email_fixture_leases (mailbox_id)
  WHERE status = 'active';
Enter fullscreen mode Exit fullscreen mode

The partial unique index prevents two active leases from claiming the same mailbox. It is the database enforcing the boundary, instead of relying on a check-then-insert sequence in application code. For related rate-limit work, the same idea pairs well with PostgreSQL partial indexes for OTP cooldowns.

When a worker claims a lease, use a transaction and lock the row it is changing. Check the expiration inside that transaction. A process that began before the lease expired should not be allowed to claim a message after another process has reclaimed the fixture.

Make the REST API idempotent

The create and start endpoints need idempotency keys. Network failures are normal in CI, and a client cannot always tell whether the server processed a request before the connection dropped. Retrying without a key can create two inboxes or send two verification messages.

Persist the key with the operation result. On a repeated request, return the original lease or attempt instead of starting a new one. The response should include a stable identifier and a state that the caller can understand:

{
  "attempt_id": "attempt_01J9XM",
  "state": "waiting_for_message",
  "lease_id": "lease_01J9XK",
  "expires_at": "2026-10-16T05:10:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Do not make the polling endpoint perform a hidden retry of the original action. It should observe state. If delivery is late, the reponse can say waiting_for_message; if the lease expired, return expired and preserve the last known event. That distinction makes a CI failure much easier to diagnose.

For Node.js services, keep the idempotency and lease checks close to the repository boundary. The controller should not have to know whether a row was claimed by a unique constraint or by an explicit lock. It only needs a stable domain result and an appropriate HTTP status.

Correlate messages without guessing

A subject line is useful for humans but weak as a primary key. Include a non-sensitive verification attempt identifier in a header or a structured metadata field when the mail provider supports it. If that is not possible, use a short token in the message body and bind it to the attempt in PostgreSQL.

The consumer should claim one message in a transaction, record the provider message ID, and then validate the token. A message that has already been claimed by another worker should produce a clear conflict or an already-processed result. It doesnt need to be downloaded and interpreted twice.

Keep the diagnostic record separate from the secret. Store the message ID, timestamps, subject hash, and delivery state, but avoid retaining the full verification token longer than required. The approach in audit-friendly auth email handling in Node.js is a useful companion pattern for keeping operational evidence without making logs a second credential store.

Release and observe every fixture

Expiration is a safety net, not the normal cleanup path. A passing test should release its lease immediately, while a failed test should release it after capturing the message ID and relevant request logs. A small reaper can mark old active leases as expired, then remove or quarantine the provider-side inbox according to its retention rules.

Expose metrics for active leases, expired leases, claim conflicts, delivery wait time, and cleanup failures. These measurements tell you whether the test system is slow, the email provider is delayed, or the application failed to send. Without them, teams often increase polling time and call the problem solved.

The clean up is also easier when the run ID is present in every log line. Keep it alongside the attempt ID, but never log the verification token or a full mailbox credential.

A practical implementation checklist

  • Give every test run an isolated lease and a bounded expiration.
  • Enforce one active lease per mailbox in PostgreSQL.
  • Use idempotency keys for lease creation and verification starts.
  • Correlate messages with an attempt ID or bound token, not only a subject.
  • Make polling observational; do not replay the original API action.
  • Claim messages atomically and record the provider message ID.
  • Release leases in normal cleanup and run a reaper for abandoned jobs.
  • Record enough evidence to explain a failure without storing secrets.

An email fixture becomes dependable when it behaves like any other production resource: it has an owner, a state machine, an expiry, and an auditable release. That model keeps disposable email testing useful without allowing inbox races to define the result of your REST API tests.

Top comments (0)