DEV Community

kevindev
kevindev

Posted on

Version Auth Emails With Attempt Receipts

In most auth systems, email delivery looks simpler than it really is. A signup or password reset request enters the REST API, one row changes state, and a worker sends the message later. Then a retry happens, a cooldown rule changes, or the user submits the form twice from two tabs. Suddenly the team is arguing about whether the second email was expected or whether the first receipt is still valid.

What has worked better for me is treating every auth email decision as an immutable attempt with its own receipt. Instead of one mutable email_status field on the user record, the backend writes a new attempt row each time policy is evaluated. That row explains why the message was queued, skipped, or superseded. It sounds small, but it makes production debugging much less fuzzy.

Why auth emails need attempt receipts

One mutable status field collapses too many decisions together:

  • did the API accept this request?
  • did cooldown policy suppress it?
  • did a worker already claim the send?
  • did a newer verification attempt replace it?

Those questions are related, but they are not the same event. When you store them in one field, retries become hard to reason about. That is extra true when disposable inboxes or abuse heuristics are part of the flow. A support ticket might mention a strange address like fake e mail com, while QA might be checking a temporary disposable mail flow in staging, and the database still only says sent.

I prefer an append-only attempt table because it preserves the sequence. You can see that attempt 17 was skipped for cooldown, attempt 18 was queued after the window expired, and attempt 19 was invalidated because the user changed their address. That history is boring in the best way. It lets the backend answer "what happened?" without guessing from current code.

What goes wrong with one mutable status field

The common shortcut is a user or session row with fields like last_email_sent_at, verification_status, and maybe email_job_id. That works for the first version, but it breaks down when behavior becomes slightly richer.

Here is the pattern I keep seeing:

  1. The first request writes queued.
  2. A second request arrives before the worker finishes.
  3. The API overwrites the same row with suppressed or requeued.
  4. Observability now reflects the latest story, not the full story.

At that point, your team starts reading logs to rebuild state. If logs rotate, or if a worker message shows up late, the explanation gets annoyingly weak. This is also where weird strings like tempail sneak into test traffic and make the investigation feel more messy than it should be.

The better boundary is to make each policy evaluation durable and immutable. Then "current state" can be derived from the newest valid receipt instead of overwritten by it.

A PostgreSQL model for immutable attempts

For PostgreSQL, I like an auth_email_attempts table plus an outbox table keyed by the same receipt id:

create table auth_email_attempts (
  receipt_id uuid primary key,
  user_id bigint not null,
  flow_type text not null,
  email text not null,
  normalized_email text not null,
  attempt_number integer not null,
  decision text not null,
  supersedes_receipt_id uuid,
  policy_snapshot jsonb not null,
  created_at timestamptz not null default now()
);

create unique index auth_email_attempts_active_idx
  on auth_email_attempts (user_id, flow_type, attempt_number);
Enter fullscreen mode Exit fullscreen mode

The important thing is not the exact schema. It is the shape of the evidence:

  • one immutable receipt per evaluated attempt
  • one policy snapshot explaining the decision
  • one clear link to any older receipt it replaced

If you want to know which email is current, query for the newest receipt that was not superseded. If you want to know why a send never happened, inspect the stored decision and snapshot. That beats reconstructing intent from four partially related tables.

Writing receipts inside the REST API transaction

The receipt should be written in the same transaction that decides whether a send may happen. Do not leave policy evaluation to a later worker if you can avoid it. Otherwise the API and worker can drift, and the final record stops matching what the caller was told.

A clean flow looks like this:

  1. Normalize the submitted email and classify the flow.
  2. Load the latest active receipt for that user and flow.
  3. Evaluate cooldown, address policy, and resend rules.
  4. Insert a new receipt row with the decision snapshot.
  5. Insert an outbox row only when the decision is queued.

That sequence gives you stable behavior under retries. If the client resubmits, the API can create a new receipt that says suppressed_cooldown without destroying the earlier queued receipt. If the worker crashes after claiming the outbox row, you still know which receipt it belonged to. It feels a bit more verbose at first, but it pays for itself pretty fast.

I also like combining this with trace-backed inbox debugging because the test can assert the receipt id before it waits on the inbox. And if your team is doing privacy reviews for staging email flows, immutable receipts make retention boundaries easier to discuss, since the schema is already explicit about what evidence you keep.

How receipts improve debugging and testing

This design helps in a few very practical ways.

First, debugging gets narrower. When a user says "I got two verification emails," you can compare two receipts and see whether the backend intentionally issued both or whether one worker retried the same job. That answer is usually visible in one query, which is nice.

Second, API behavior becomes easier to test. A backend test can assert that a resend creates a new receipt with supersedes_receipt_id set, instead of just checking whether some timestamp moved. That is a much stronger contract, and honestly more maintainable.

Third, support and product stop relying on fuzzy timelines. The receipt ids give everyone the same vocabulary. Attempt 42 was queued. Attempt 43 was suppressed. Attempt 44 replaced 42 after the cooldown. Small thing, big clarity.

Q&A

Do I still need an outbox table?

Yes. The receipt explains the decision; the outbox handles delivery work. Keep those concerns separate, even if they share identifiers.

Is this overkill for a small app?

Not really. If your product has signup, reset, or magic-link flows, immutable receipts are one of those small backend habits that age suprisingly well.

Should I keep every receipt forever?

Usually no. Keep them long enough for support, incident review, and trend analysis, then archive or delete based on policy.

Top comments (0)