kevindevUse PostgreSQL send receipts to keep password reset APIs replay-safe when workers retry, crash, or acknowledge late.
Password reset flows usually break in a very boring place: the API says "accepted", the worker says "sent", and nobody can prove whether those two events refer to the same email attempt. When retries arrive, teams often add more flags or more logs. That helps a bit, but it still leaves an ugly gap between request intent and delivery evidence.
For Authentication systems, I trust a different pattern more. Instead of storing only reset_email_sent=true, store a send receipt row that belongs to the reset attempt. The API creates the attempt, the worker claims it, and the provider ack is reconciled back onto the same record. It sounds slightly heavier, but it makes resend behavior much less spooky under load.
The failure pattern is common:
The weak point is usually not SMTP. It is the missing contract between the REST API and the async worker. If the only durable signal is sent_at, you cannot answer simple questions very well:
That ambiguity makes incident review slower than it should be, and honestly a bit irritating.
The receipt row I like has these fields:
reset_request_iduser_idtoken_versiondelivery_state in pending | claimed | provider_acked | delivered | failed
provider_message_idclaim_ownerclaimed_atprovider_acked_atlast_errorThe important idea is that the worker does not "send an email job" in the abstract. It advances one receipt row through state transitions. That gives the API a stable place to reason about retries. If the same user hits reset again within a short cooldown, the API can decide whether to reuse the current active receipt or create a newer reset attempt and invalidate the old token version.
PostgreSQL is good at this because it can make the claim step atomic. The worker either owns one receipt or it does not. There is less hand-wavy middle state, which is where duplicate email behavior loves to hide.
This is the shape I use when a worker claims pending reset receipts:
with next_receipt as (
select id
from password_reset_receipts
where delivery_state = 'pending'
order by created_at
for update skip locked
limit 1
)
update password_reset_receipts pr
set delivery_state = 'claimed',
claim_owner = $1,
claimed_at = now()
from next_receipt
where pr.id = next_receipt.id
returning pr.id, pr.reset_request_id, pr.token_version, pr.user_id;
After the provider accepts the send, I do a second update that records the ack on the same row:
update password_reset_receipts
set delivery_state = 'provider_acked',
provider_message_id = $2,
provider_acked_at = now()
where id = $1
and delivery_state = 'claimed';
That extra provider_acked state matters more than it first appears. If the worker crashes after the provider call but before your final "sent" update, you can reconcile from the provider message id instead of blasting out another reset email. Stripe has written well about idempotent API design and why replay boundaries must be explicit (https://stripe.com/blog/idempotency). Same principle here, just at the email edge of the backend.
In Node.js services, I keep the resend endpoint boring:
if (activeReceipt && activeReceipt.delivery_state !== "failed") {
return { status: 202, resetRequestId: activeReceipt.reset_request_id };
}
await createResetAttempt(db, userId);
return { status: 202 };
It is not fancy, but it is reviewable. Reviewable systems tend to fail more gracefuly.
I would not center the architecture around inbox tools, but they are still useful for proving that one reset attempt becomes one human-visible email. For backend regression tests I like combining DB assertions with a throwaway email inbox, so I can verify both the receipt state and the actual delivered message.
That pairs well with isolated inbox checks and queue safe reset email checks, because both encourage evidence you can trace back to one run instead of shared mailbox noise.
When I need a disposable inbox for manual verification, I keep it in a narrow test section and move on. One contextual option is a fake emails generator, but the real control point is still your receipt table. Even if someone writes tempail in an ops note or test case, the backend contract should stay deterministic.
I also like keeping a tiny checklist for reset-email incidents:
That checklist saves time becuase the team stops arguing with logs and starts reading the same durable facts.
Because the user row answers identity questions, not delivery lifecycle questions. Mixing both usually gets messy once retries and multiple reset attempts exist.
Yes. Database claims protect your worker coordination. Provider idempotency protects the outbound API call. They cover different failure windows, and together they work much better.
provider_acked be treated as fully sent?
Not always. It means the provider accepted the request. If you track webhooks or bounces later, keep delivered as a separate state. That separation is maybe a little more work, but it pays off later.