DEV Community

DonovanPierce4012
DonovanPierce4012

Posted on

Password Reset Email: Prevent Duplicate Sends When Retry Meets a Timeout

Short answer: treat a timeout as an unknown delivery outcome, persist one account-recovery intent before sending, and retry only after you reconcile that intent. Exactly-once delivery is not a property an ordinary email boundary can prove; exactly-once intent creation and one reusable reset credential are achievable.

A password reset flow has two clocks. The browser has a short HTTP deadline. Mail infrastructure can accept a message after that deadline and deliver it minutes later. If the request handler interprets every timeout as a failed send, one user action can create multiple emails and several apparently different links. The fix starts with durable identities, not a more aggressive retry count.

How can a password reset email retry after a timeout without creating multiple links?

Give the user request an idempotency key. Store an account-recovery intent under that key with a uniqueness constraint, then derive one credential lineage from the intent. A second request with the same key reads the existing intent. It does not rotate the credential or enqueue another logical message.

Keep delivery attempts separate from the intent. An attempt records a handoff, its adapter reference when available, timestamps, and an outcome such as accepted, rejected, or unknown. A deadline-expired call belongs in unknown. It is evidence that your client did not observe a response, not evidence that the provider did not accept the message.

That distinction is small and practical.

The worker can reconcile an unknown attempt by querying a supported message identity, consuming a delivery event, or waiting through a documented uncertainty window. If the transport provides none of those signals, the honest policy is at-most-once handoff or a user-visible new request; a blind retry cannot be called exactly once.

Fast response. Slow certainty.

Put the database boundary before the mail boundary

Use one database transaction for the state your service can make atomic: get-or-create the intent, create its reset credential representation, and insert an outbox row keyed by the intent. Do not hold the transaction open while making a network call. A rollback cannot recall an email that was already accepted, and long mail latency unnecessarily holds locks.

The following Python sketch uses generic repository methods. The important behavior is enforced by storage constraints, not by the method names.

from dataclasses import dataclass
from datetime import datetime, timezone

@dataclass(frozen=True)
class RecoveryRequest:
    account_ref: str
    idempotency_key: str

def accept_request(db, request: RecoveryRequest):
    with db.transaction() as tx:
        intent = tx.recovery_intents.insert_or_get(
            idempotency_key=request.idempotency_key,
            account_ref=request.account_ref,
        )
        tx.credentials.ensure_for_intent(intent.id)
        tx.outbox.insert_or_ignore(
            logical_key=f"recovery-email:{intent.id}",
            intent_id=intent.id,
            state="pending",
        )
    return {"accepted": True}

def deliver(db, mailer, outbox_id: str):
    item = db.outbox.claim(outbox_id)
    if item.state != "pending":
        return

    try:
        receipt = mailer.submit(
            logical_key=item.logical_key,
            template="account_recovery",
            variables={"intent_id": item.intent_id},
        )
    except TimeoutError:
        db.outbox.mark_unknown(
            item.id, observed_at=datetime.now(timezone.utc)
        )
        return

    db.outbox.mark_accepted(item.id, receipt.reference)
Enter fullscreen mode Exit fullscreen mode

insert_or_get and insert_or_ignore need unique indexes that cover the idempotency key and logical message key. Two application processes can pass an in-memory check at the same time. A database constraint is the arbiter. The worker also needs a lease or claim record so a crashed process can be recovered without letting two workers submit concurrently.

What should the retry state machine record for reset links and multiple emails?

Names vary, but the transitions should be explicit: pending to in_flight, then to accepted, rejected, or unknown; a reconciliation decision can move unknown to accepted or to a terminal state. An expired lease returns an abandoned in_flight row to a controlled recovery path. It should not mint a new intent.

Observation Durable state Next action New link?
Nothing submitted pending Claim and submit No
Positive provider acceptance accepted Stop submitting No
Client timeout unknown Reconcile or apply policy No
Explicit transient rejection before acceptance pending Retry under bounded policy No
A genuinely new user flow New intent Apply rate and security policy Maybe

Credential redemption is a separate transaction. Check the intent, expiry, and one-time-use rule atomically when the link is consumed. If two copies of one message escape, both should reference the same credential semantics. That reduces security and support impact, but it does not make duplicate mail acceptable.

Email standards solve different problems. RFC 6376 describes DKIM signing for message content and domain identity; a valid signature says nothing about whether your application created one business action or two. Preserve canonical message bytes through signing and log the signing domain and selector as metadata. For SMS fallback, consent, sender identity, and throughput rules are part of the workflow. CTIA's messaging interoperability and compliance guidance is a useful baseline. Fallback should remain tied to the same recovery intent, not fan out automatically after an ambiguous email handoff.

How do I test password reset email retries after a timeout?

A meaningful test double records acceptance and then raises TimeoutError before returning a receipt. Run the worker again and assert one intent, one credential lineage, and one logical message key. Assert that no second submission happens until reconciliation has evidence. This catches the exact branch that ordinary happy-path tests miss. I also inject a 408-style client deadline, a dropped TCP response, and a delayed acceptance event in separate runs, because those observations look similar to the web tier while requiring different state transitions. The test report should show the logical key, not just a count of worker calls, so a reviewer can distinguish one uncertain handoff from two real sends.

Add races: two HTTP requests with one idempotency key, two workers claiming one outbox row, a process killed after provider acceptance but before the database update, and a delayed delivery event arriving after the uncertainty window. Verify that logs contain opaque intent, attempt, and message IDs rather than reset credentials or full message bodies — that detail matters during an incident, when copying a token into a ticket can become a second security problem.

Metrics should be keyed by logical message, not by worker invocation. Track unique intents, accepted handoffs, unknown age, attempts per intent, and divergence between accepted receipts and unique logical keys. A spike in attempts per intent is an operational signal; it is not proof that the provider duplicated a message. I'm not sure any team can remove every ambiguous outcome at a third-party boundary, so the runbook should state what evidence closes the window and who makes the terminal decision.

When is this pattern the wrong fit?

The catch is operational ownership. An outbox brings leases, cleanup, reconciliation, dashboards, and an on-call policy for rows that remain unknown. It is not suitable when the transport has no stable message identity and the business cannot tolerate an uncertainty window. Choose an at-most-once handoff in that case, document that a user may need to start a new recovery request, and avoid claiming exactly-once delivery.

A simpler synchronous send can be reasonable for low-impact notifications where occasional duplication is acceptable. Password recovery is different because repeated messages look suspicious and a fresh credential can widen the security surface. A queue with duplicate suppression may reduce repeated work, but its suppression window is not the same as business idempotency.

Roll out gradually: first record intent and logical keys in shadow mode, then move message creation into the transaction for a small traffic slice, then enable reconciliation and remove direct sends from the request path. Keep a switch that pauses workers without deleting durable rows. That gives operators time to inspect unknown attempts while preserving the audit trail.

The review question is simple: after a timeout, crash, or duplicated request, can the team point to one durable intent and explain the next transition from evidence? If not, another retry flag will only make the ambiguity louder.

References

Top comments (0)