DEV Community

kevindev
kevindev

Posted on

Replay-Safe Email Verification Receipts

Email verification looks like a short request: create a token, send a message, and return success. The difficult case starts when the provider accepts the message but the network timeout reaches your service first. The worker cannot know whether it is safe to retry, and the user may receive two links.

I prefer treating the delivery result as a replay-safe receipt. The application records one logical verification, gives it a stable delivery ID, and lets a worker update that record with provider evidence. A retry can then repeat the operation without creating a second verification intent.

This is a backend boundary, not just an email feature. It keeps a REST API predictable, makes PostgreSQL state useful during incidents, and gives operators something better than “the request timed out.”

The timeout that creates two verification emails

The first implementation usually look simple:

  1. Insert a verification token.
  2. Call the mail provider.
  3. Mark the token as delivered.

If step 2 times out, the caller may retry the API request. A second token or outbox row is created, even though the first provider call might have succeeded. The result is confusing for users and hard to diagnose from logs.

The fix is to separate intent from attempt. A verification intent says which account is being verified and when the token expires. A delivery attempt says which worker tried to send it, with what idempotency key, and what the provider returned. Those records can be related without being the same thing.

What a delivery receipt should represent

A receipt is not proof that the user clicked the link. It is a durable statement about the delivery boundary:

  • delivery_id identifies one logical message.
  • idempotency_key identifies the provider operation.
  • status distinguishes pending, accepted, failed, and unknown.
  • provider_message_id stores external evidence when available.
  • attempt_count, last_error, and next_attempt_at explain retry decisions.
  • expires_at prevents an old token from being sent after it is useless.

The unknown status is important. A timeout is not always a failure. Marking it as failed and immediately sending again can produce duplicates; marking it accepted without evidence can hide a real outage. A bounded retry policy can resolve unknown outcomes while preserving the original delivery ID.

The receipt should also carry a correlation ID, but not the token or the full email address in ordinary logs. A tempail mail address appearing in a debug line may look harmless, yet retained logs often have a wider audience than the test inbox.

Use PostgreSQL to make replay safe

Create one outbox row per logical verification and enforce that rule in the database:

CREATE UNIQUE INDEX one_active_verification_delivery
ON email_outbox (verification_id)
WHERE status IN ('pending', 'sending', 'accepted', 'unknown');
Enter fullscreen mode Exit fullscreen mode

For the request path, use an idempotency key and a unique constraint scoped to the account or client. If the same request arrives again, return the existing verification response. If the payload differs, return a conflict instead of silently changing the recipient.

Workers can claim pending rows without waiting behind one another:

WITH next_delivery AS (
  SELECT id
  FROM email_outbox
  WHERE status IN ('pending', 'unknown')
    AND next_attempt_at <= now()
    AND (lease_until IS NULL OR lease_until < now())
  ORDER BY next_attempt_at, id
  FOR UPDATE SKIP LOCKED
  LIMIT 20
)
UPDATE email_outbox AS e
SET status = 'sending',
    lease_owner = $1,
    lease_until = now() + interval '90 seconds',
    attempt_count = attempt_count + 1
FROM next_delivery
WHERE e.id = next_delivery.id
RETURNING e.*;
Enter fullscreen mode Exit fullscreen mode

Commit the claim before making the network call. When the response arrives, update by both id and lease_owner. A worker whose lease expired must not mark a row accepted after another worker has reclaimed it. This condition matter more than the exact queue product.

For transient failures, use backoff and a ceiling. For permanent failures, record the provider category and stop retrying. A clean failure record make an incident searchable without replaying the whole request.

Keep the REST API contract stable

The API should expose the state that a client can act on, not internal provider details. For example:

{
  "verification_id": "ver_123",
  "delivery_status": "accepted",
  "expires_at": "2026-10-21T01:00:00Z",
  "retryable": false
}
Enter fullscreen mode Exit fullscreen mode

When delivery is unknown, the client can show that the message is being checked and poll a status endpoint. It should not create another verification just because the original HTTP request had a timeout. Teams often does the opposite because the retry path is easier to add than the state model.

For controlled staging checks, a tempmailso inbox can isolate test traffic from real customer mail, but it does not replace idempotency or provider-side evidence. The useful contract remains in your database.

Test the receipt, not only the inbox

End-to-end tests should simulate the ambiguous cases: provider accepts then times out, worker crashes after claiming, lease expires during a slow send, and the same API request arrives twice. Assert the number of logical deliveries as well as the visible message.

A fixture factory for email tests can make those scenarios repeatable across test cases. At the deployment layer, keep the same evidence mindset as a deploy receipt that explains a CI result: record what happened, which run owned it, and what should happen next.

Do not assert only that an inbox eventually contains a message. Assert that the message belongs to the expected delivery_id, that a late worker cannot overwrite a newer state, and that retry exhaustion produces an actionable error. The test is more useful when it can explain why a delivery was accepted or retried.

Operational checklist

Before shipping this flow, verify:

  1. One unique delivery exists for each logical verification.
  2. Repeated API requests return the same result for the same idempotency key.
  3. Worker claims use short transactions and SKIP LOCKED.
  4. Result updates require a current lease owner.
  5. Unknown provider outcomes have bounded retries and alerts.
  6. Expired tokens cannot be sent by an old worker.
  7. Logs contain correlation IDs instead of tokens or raw message bodies.

The main design decision is to make uncertainty a stored state. Once the delivery receipt is durable, retries become controlled replays instead of guesses, and a provider timeout becomes a case the system can explain.

Top comments (0)