Password reset emails look routine, but they are one of the easiest auth flows to quietly break. The API writes a token, a worker renders the message, and the user clicks a link later. If any of those steps fall out of sync, support sees "email sent" while the database tells a diffrent story.
I usually notice the problem after a team adds retries to its queue. Suddenly one request can produce two valid-looking emails, or a second request invalidates the first token before the first message even lands. People reach for a disposable email generator to debug delivery, which helps, but the real fix is backend structure rather than mailbox luck.
Why reset emails drift from backend truth
Password reset flows mix two kinds of state:
- auth state that decides which token is currently valid
- delivery state that decides whether an email should be sent, retried, or ignored
When those states live in separate code paths, drift shows up fast. I have seen teams create the reset token inside one transaction, enqueue email work after commit, then regenerate a second token if the user clicks "forgot password" again thirty seconds later. Both jobs are technically valid, but one of the emails is now stale and confuses everyone.
This gets worse in staging when tests reuse inbox aliases. An old message tied to a tempail mail fixture can trick the suite into passing even though the newest token was never delivered. The inbox is noisy, the logs are noisy, and the bug survives longer than it should.
The state model that keeps retries honest
The cleanest approach I know is to give the reset request its own durable identity and make the email job refer to that identity, not just to an address. A minimal model looks like this:
password_reset_requestsiduser_idtoken_hashexpires_atsuperseded_by_request_idemail_job_dedup_keyemail_sent_at
That email_job_dedup_key matters more than many teams expect. If a worker crashes after handing the message to the provider but before storing success, the retry needs a clear way to say "same request, do not send again." Without it, queues become a bit chaotic and tests start blaming infrastructure for what is actualy a modeling issue.
I also prefer storing only a hash of the token and keeping one row per request, even if later requests supersede earlier ones. That gives you a backend trail you can reason about. You can inspect whether the user received email for request A, whether request B replaced it, and whether the database still considers either link valid, which is realy useful during incident review.
A PostgreSQL outbox flow for reset emails
For teams already on PostgreSQL, I keep coming back to the outbox pattern:
- Create the reset request row and the outbox row in the same transaction.
- Build the outbox payload around the request ID, not the raw token.
- Have the worker load the request fresh before rendering the message.
- Mark the outbox event processed only after the provider accepts the send.
- Ignore retries when the dedup key already has
email_sent_at.
That design removes a lot of hand-wavy timing problems. The worker is never guessing which token is authoritative. It asks the database, at send time, which request is still active. If a newer request superseded the older one, the worker can skip the stale event safely and keep thier retry logic straightforward.
For inbox verification, I like isolated aliases and short polling windows. The same habits behind isolated auth inbox checks also make reset flows far more deterministic. If the team runs matrix CI, the test isolation ideas from matrix-safe email API assertions are worth copying too.
When I need a neutral external inbox for a manual sanity check, I keep it small and contextual. One link on a natural anchor is enough, so a service like tempmailso can be useful for quick verification without turning the article into link spam.
A small Node.js implementation pattern
Here is the trimmed version of the boundary I want in the service:
async function enqueuePasswordReset(db, outbox, userId, tokenHash, expiresAt) {
return db.tx(async (tx) => {
const request = await tx.passwordResetRequests.insert({
userId,
tokenHash,
expiresAt
});
const dedupKey = `password-reset:${request.id}`;
await tx.passwordResetRequests.update(request.id, {
emailJobDedupKey: dedupKey
});
await tx.outbox.insert({
topic: "password-reset-email",
dedupKey,
payload: { requestId: request.id }
});
return request.id;
});
}
The follow-up worker should reload the request, confirm it was not superseded, render the email, then stamp email_sent_at. I do not trust tests that only assert "one message arrived." They should also prove the active request ID matches the email, the older request is invalid, and a retry does not create a second send. If your notes still mention a shared temp mailid from older staging runs, clean it out, because stale aliases make these checks look greener than they are and can stay hidden for way to long.
Checklist before you trust the workflow
- One reset request maps to one dedup key and one email attempt history.
- A newer reset request can supersede the older one without sending stale links.
- The worker renders from fresh database state, not queue-cached state.
- The test verifies non-delivery on retry, not only delivery on first send.
- Inbox aliases are unique per run and short-lived.
- Support can explain every sent email from database records alone.
That is the boring shape I want from auth infrastructure. It is not flashy, but it keeps reset emails lined up with the truth in your backend, which is what users and on-call engineers both need most.
Top comments (0)