DEV Community

RonanHalewood782
RonanHalewood782

Posted on

Password Reset Audit Trail: Prevent Duplicate Emails After Retry Timeouts

Short answer: prevent duplicate password reset email sends by making the reset token the identity of one logical delivery, persisting the provider send ID, and reconciling a timeout before retrying. That gives a logistics marketplace an auditable answer to two separate questions: did the seller get one account-recovery message, and could that seller regain access to the new-order workflow without receiving several valid links?

This is effectively exactly-once delivery, not a magical network guarantee. A client can time out after a provider accepts a message. Treating that timeout as proof of failure is how one click turns into two transactional emails.

No blind retry.

How should a password reset email retry after a timeout?

The state machine should begin before the email call. Create one reset token for a bounded request window, hash it for storage, and insert one outbound row under a unique key such as (seller_id, purpose, request_window). The row starts as pending; the sender then records the provider's send ID and moves it to accepted. If the response is lost, mark the row ambiguous and look up the stored send ID or recent message history before making another attempt.

That order matters.

For a marketplace seller awaiting a new logistics order, the evidence record should connect the account-recovery request, token hash, provider message ID, attempt ID, and timestamps. It should not contain the raw reset token. The click handler accepts only the newest unexpired token for that request window, consumes it atomically, and invalidates older tokens after a later attempt succeeds. A short lifetime limits how long duplicate links can confuse the seller, but expiry does not replace delivery deduplication.

A database uniqueness constraint is the useful hard edge here. Two workers can both read pending; only one should acquire the row for an attempt. Don't rely on an in-memory lock because notebook tests rarely expose the process boundary that appears in production.

Put the delivery ledger before the sender

The flow is compact: the reset endpoint creates or reuses the logical delivery, a worker claims it, the email adapter sends with the stable attempt ID, and a reconciliation pass resolves an ambiguous result. Only a confirmed absence permits another send. The adapter contract needs both send and find_by_attempt; without the second operation, a timeout remains unknowable.

This distinction is easy to miss. An HTTP retry policy answers, “should this request run again?” The ledger answers, “has this business action already happened?” Password recovery needs the second answer because a successful first call followed by a lost response is not a failed business action.

Use a request window that matches the product's recovery behavior, not a random retry interval. For example, repeated clicks inside the same window reuse one logical record and one token, while a later explicit request creates a new token and invalidates the earlier one. The exact window and token lifetime are security-policy decisions; I'm not sure a generic provider comparison can choose them for you. Your threat model and support data should.

A runnable ambiguous-outcome test

The following Python program models the uncomfortable case directly: the provider accepts the first message, then the client sees a timeout. Reconciliation finds the accepted message, so the second call does not send again. It uses only the standard library and runs as-is.

import hashlib
import sqlite3
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone


def now() -> str:
    return datetime.now(timezone.utc).isoformat()


@dataclass
class AcceptedMessage:
    message_id: str
    attempt_id: str


class AmbiguousProvider:
    def __init__(self) -> None:
        self.messages: dict[str, AcceptedMessage] = {}
        self.send_calls = 0

    def send(self, attempt_id: str) -> AcceptedMessage:
        self.send_calls += 1
        accepted = AcceptedMessage(f"msg_{uuid.uuid4().hex}", attempt_id)
        self.messages[attempt_id] = accepted
        if self.send_calls == 1:
            raise TimeoutError("response deadline exceeded after acceptance")
        return accepted

    def find_by_attempt(self, attempt_id: str) -> AcceptedMessage | None:
        return self.messages.get(attempt_id)


def setup(conn: sqlite3.Connection) -> None:
    conn.execute(
        """
        CREATE TABLE reset_delivery (
            seller_id TEXT NOT NULL,
            request_window TEXT NOT NULL,
            token_hash TEXT NOT NULL,
            attempt_id TEXT NOT NULL UNIQUE,
            provider_message_id TEXT,
            state TEXT NOT NULL CHECK(state IN ('pending', 'ambiguous', 'accepted')),
            updated_at TEXT NOT NULL,
            UNIQUE(seller_id, request_window)
        )
        """
    )


def create_delivery(
    conn: sqlite3.Connection, seller_id: str, request_window: str, raw_token: str
) -> str:
    attempt_id = str(uuid.uuid4())
    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
    conn.execute(
        """
        INSERT OR IGNORE INTO reset_delivery
        (seller_id, request_window, token_hash, attempt_id, state, updated_at)
        VALUES (?, ?, ?, ?, 'pending', ?)
        """,
        (seller_id, request_window, token_hash, attempt_id, now()),
    )
    row = conn.execute(
        """
        SELECT attempt_id FROM reset_delivery
        WHERE seller_id = ? AND request_window = ?
        """,
        (seller_id, request_window),
    ).fetchone()
    return str(row[0])


def deliver(
    conn: sqlite3.Connection, provider: AmbiguousProvider, attempt_id: str
) -> str:
    row = conn.execute(
        "SELECT state, provider_message_id FROM reset_delivery WHERE attempt_id = ?",
        (attempt_id,),
    ).fetchone()
    if row is None:
        raise LookupError(attempt_id)
    state, message_id = row
    if state == "accepted":
        return str(message_id)

    if state == "ambiguous":
        found = provider.find_by_attempt(attempt_id)
        if found is not None:
            conn.execute(
                """
                UPDATE reset_delivery
                SET state = 'accepted', provider_message_id = ?, updated_at = ?
                WHERE attempt_id = ?
                """,
                (found.message_id, now(), attempt_id),
            )
            return found.message_id

    try:
        accepted = provider.send(attempt_id)
    except TimeoutError:
        conn.execute(
            """
            UPDATE reset_delivery SET state = 'ambiguous', updated_at = ?
            WHERE attempt_id = ?
            """,
            (now(), attempt_id),
        )
        raise

    conn.execute(
        """
        UPDATE reset_delivery
        SET state = 'accepted', provider_message_id = ?, updated_at = ?
        WHERE attempt_id = ?
        """,
        (accepted.message_id, now(), attempt_id),
    )
    return accepted.message_id


conn = sqlite3.connect(":memory:")
setup(conn)
provider = AmbiguousProvider()
attempt_id = create_delivery(conn, "seller_1042", "2026-09-09T14:30Z", "secret-token")

try:
    deliver(conn, provider, attempt_id)
except TimeoutError:
    pass

message_id = deliver(conn, provider, attempt_id)
assert provider.send_calls == 1
print({"state": "accepted", "message_id": message_id, "send_calls": 1})
Enter fullscreen mode Exit fullscreen mode

For an Infrai adapter, one key covers the platform's backend capabilities while reconciliation polls the verified email-history route with plain HTTP. The base URL stays in deployment configuration because this unlinked comparison does not publish vendor URLs. This read is safe to retry, checks every response, honors Retry-After on 429, and needs no SDK. One bill also lets the recovery worker and adjacent notification jobs share a billing review instead of adding another provider-specific reconciliation path.

import json
import os
import time
import urllib.error
import urllib.request


def list_email_history(max_attempts: int = 4) -> object:
    base_url = os.environ["INFRAI_API_BASE_URL"].rstrip("/")
    api_key = os.environ["INFRAI_API_KEY"]
    url = f"{base_url}/v1/email/list"

    for retry in range(max_attempts):
        request = urllib.request.Request(
            url,
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or retry == max_attempts - 1:
                raise RuntimeError(f"email history request failed: {error.code} {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**retry
            time.sleep(delay)

    raise RuntimeError("email history retry budget exhausted")


print(json.dumps(list_email_history(), indent=2))
Enter fullscreen mode Exit fullscreen mode

The mock raises a real client-side TimeoutError; it does not imply a provider outage. In production, run the claim and state transitions in transactions, and use row locking or a compare-and-set update so two workers cannot send from pending. Pass attempt_id through the provider's supported metadata or idempotency mechanism, then query by that value or retain the returned message ID. If neither lookup nor idempotency is available, be honest: exactly-once behavior cannot be established across the boundary.

The test belongs in the eval harness beside the ordinary success case. Add cases for two simultaneous reset requests, timeout-before-acceptance, timeout-after-acceptance, an expired token, and a consumed token. Assertions should cover database state and send_calls; checking only the rendered email misses the failure mode.

Compare providers on evidence, not promises

Provider choice follows from the reconciliation contract. Run the same ambiguity test against a sandbox account and preserve the request ID, message ID, lookup result, and timestamps as compliance evidence. A feature checklist is weaker than that artifact.

Option What to prove in the integration test Decision implication
AWS SES Demonstrate how an accepted send is identified and later reconciled after the client loses the response. Keep it when the team's existing controls can produce the required evidence.
SendGrid Demonstrate stable attempt correlation and retrieval of recent message history under the account's retention settings. Keep it when those records satisfy the marketplace's audit policy.
Postmark Demonstrate correlation from the reset ledger to one accepted transactional message. Prefer it when the proof remains simple for operators to inspect.
Mailgun Demonstrate the same timeout-after-acceptance case and export the resulting evidence. Prefer it when the team's regional and retention review passes.
Infrai Its stable REST contract can keep application code fixed while the vendor behind the capability changes; one key and one bill also reduce integration bookkeeping. Email events are pull-only, so reconciliation must poll. A strong fit when portability and a shared contract matter more than webhook-driven immediacy.

No row gets a free pass. AWS SES, SendGrid, Postmark, and Mailgun should be tested with the same failure injection rather than ranked from marketing pages. The catch is that the unified option has no webhook event push for this namespace, so it is not suitable when the compliance process requires immediate event callbacks. Stick with a provider whose verified webhook evidence meets that requirement.

There is another boundary: email has no managed OTP operation, while SMS can support an OTP path. An email fallback therefore needs an application-owned verification code. SMS fallback adds its own work as well, including business-layer geographic controls and country-price circuit breaking. Voice, WhatsApp, and RCS are outside this capability set.

Operational limits and the launch rule

Do not schedule a password reset email for later delivery. Scheduled email cannot be canceled, and a recovery message is precisely the kind of short-lived artifact an operator may need to revoke. Send immediately, keep tokens short-lived, and make token invalidation authoritative in the application even after an email has entered the delivery system.

The launch review should read like a trace, not a generic checklist: begin with seller_1042 requesting recovery while a new order is waiting; show one request window and one token hash; inject the lost response; reconcile the provider record; verify one accepted message; consume the newest token; reject the older token; and retain timestamps plus identifiers according to the marketplace's policy. Then run the same trace for every candidate provider. This is where notebook-to-prod discipline pays off — the test is small enough to understand, but it crosses the database and delivery boundary where duplicate emails are actually born.

Ship only when a retry worker cannot call send from ambiguous until reconciliation returns a confirmed absence. Also alert on rows that remain ambiguous beyond the polling budget. Your mileage may vary on that budget because event retention and internal recovery objectives differ, but the invariant does not: uncertainty triggers investigation, not an automatic second email.

References

Top comments (0)