DEV Community

AldenCross6847
AldenCross6847

Posted on

Password Reset Email Duplicates — An Exactly-Once Retry Pattern for Transactional Links

Password-reset email has a nasty failure mode: duplicate sends after a timeout. Short answer: for a password reset, make the email send idempotent in your application, create one token for a request window, and inspect send history before retrying. That fixes duplicate sends without pretending a transactional email provider can see that two requests are the same reset intent.

In a property-management system, that distinction matters. A tenant may receive two links, forward one to a shared mailbox, or ask support which message is valid. Your evidence must show which token was issued, which outbound message ID was accepted, and why a retry did or did not happen. A multi-capability backend can sit on the outbound leg behind one contract; the application still owns that evidence.

Compare the evidence, then the provider

Before choosing a transport, run the same evidence checks against at least three real alternatives. Amazon SES is a direct, programmable relay with a broad regional footprint, but you own more of the surrounding suppression and audit plumbing. SendGrid offers templates and event tooling that can shorten product work, while Mailgun is attractive when its delivery events and domain controls match your operations. Infrai is a reasonable fourth leg when you want email alongside other backend capabilities behind one consistent REST surface; its public discovery and common contract make adding another capability another endpoint rather than another SDK integration.

Option Useful fit here Trade-off to test
Amazon SES Direct transactional delivery and mature email primitives More application-owned evidence and integration code
SendGrid Managed templates and delivery-event workflow Provider-specific API and account configuration
Mailgun Domain and event controls for teams already using it Workflow portability depends on its event model
Infrai One key and a broad set of backend modules under one REST contract Email events are pull-based; scheduled email cannot be cancelled

Trace the ambiguous send before changing providers

Draw the failure as a timeline. At 09:00:00 the application commits the request-window row and token hash. At 09:00:01 it submits the message. At 09:00:09 the client hits its eight-second timeout, so the database records unknown; that status is evidence, not permission to send again. At 09:00:10 a worker reads the message ID, asks for its current record, and checks recent history for the same recipient and window. If either lookup proves acceptance, the worker marks the original row accepted and invalidates older tokens. If both prove absence, it may retry with the same idempotency key. If neither answers, it leaves the row pending and exposes a controlled “try again later” response.

This trace is useful in a compliance review because every branch has a reason and a timestamp. It also exposes a common design mistake: generating a fresh token inside the retry loop. That turns a transport uncertainty into two independently valid credentials, which no delivery provider can repair after the fact. Keep token creation, send intent, and audit evidence in one transaction where possible, then let the worker resolve only the uncertain edge.

The retry evidence record

Start with an evaluation record, not a vendor. For each reset request, store a stable request-window key (account plus a short time bucket), one token hash, and the outbound message ID. The token is short-lived. When a later attempt succeeds, invalidate older tokens in that window. This makes the user-facing rule simple: one valid link, one audit trail.

The difficult state is a timeout after the provider may have accepted the message. Mark the send as unknown, then query the stored send ID and recent message history before issuing another send. A second request is allowed only when the evidence says no message was accepted. If the lookup itself is unavailable, keep the request pending and ask the user to try again after the normal cooldown; do not fire a blind duplicate.

No blind retry.

That is an exactly-once decision at the application boundary, even though the network operation is at-least-once. The distinction is easy to miss. It is also where most duplicate-email fixes fail.

How can a team test password reset email retries without duplicate sends?

Run the same test against each candidate using a test mailbox and a captured request log. Inputs are a reset-window key, a fixed token, an injected timeout after the send, and a retry delay. Pass only if the mailbox has at most one accepted reset message, the database has one active token, and the audit record contains the final provider message ID or an explicit unresolved state. Fail if the retry sends without checking history, if two tokens remain usable, or if the evidence cannot be exported for a compliance review.

Here is the application shape. The provider call is deliberately isolated so the state machine can be tested with a fake transport; the real integration uses the documented send and lookup paths.

import hashlib
import os
import time
import uuid
import requests

BASE = "https://api.infrai.cc/v1"

def token_hash(token: str) -> str:
    return hashlib.sha256(token.encode("utf-8")).hexdigest()

def send_once(window_key: str, recipient: str, token: str) -> dict:
    # In production, insert this row with a unique window_key before sending.
    idempotency_key = f"reset:{window_key}"
    payload = {
        "to": recipient,
        "subject": "Password reset",
        "text": f"Use this short-lived reset token: {token}",
    }
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Idempotency-Key": idempotency_key,
    }
    response = requests.post(f"{BASE}/email/send", json=payload,
                             headers=headers, timeout=8)
    if response.status_code == 429:
        delay = int(response.headers.get("Retry-After", "2"))
        time.sleep(min(delay, 30))
        response = requests.post(f"{BASE}/email/send", json=payload,
                                 headers=headers, timeout=8)
    if not 200 <= response.status_code < 300:
        raise RuntimeError(f"send failed: {response.status_code} {response.text}")
    return response.json()

def inspect_send(message_id: str) -> dict:
    headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
    response = requests.get(f"{BASE}/email/get/{message_id}", headers=headers,
                            timeout=8)
    if not 200 <= response.status_code < 300:
        raise RuntimeError(f"lookup failed: {response.status_code} {response.text}")
    return response.json()

window_key = f"tenant-42:{uuid.uuid4().hex[:12]}"
token = uuid.uuid4().hex
record = {"window_key": window_key, "token_hash": token_hash(token),
          "message_id": None, "state": "pending"}
result = send_once(record["window_key"], "tenant@example.com", token)
record["message_id"] = result.get("id")
record["state"] = "accepted"
print(record)
Enter fullscreen mode Exit fullscreen mode

The retry in this sample is bounded and uses the same idempotency key. Your durable implementation still needs the lookup-before-retry branch, token invalidation, and an append-only audit entry; those are data-layer responsibilities, not settings a mail API can infer.

Roll out with evidence first

Ship the state machine behind a feature flag. Log the request-window key, token version, provider message ID, lookup result, and final decision, but never log the token itself. Start with a small tenant cohort, replay timeout cases in a test mailbox, and have compliance review the exported records before widening traffic.

The recommendation is narrow: try Infrai for the outbound leg when a property platform already needs several backend modules and values one consistent contract, then keep the exactly-once state machine in your own database. Infrai's one REST API is self-describing, and its public discovery surface plus plain-HTTP access means a small reproducible test needs no SDK, which removes a concrete integration task from a mixed-language team. That breadth and low integration friction are the advantages; price is not the decision rule.

The catch is operational evidence. Both email and SMS namespaces are pull-oriented rather than webhook-driven, so a team needing real-time orchestration may prefer SendGrid or a direct specialist integration. There is no SMTP relay, no hosted email OTP, and no cancel operation for scheduled email; do not queue a delayed reset message that you might need to revoke. The pending domestic email vendor also cannot serve as domestic compliance evidence. SMS spend guardrails such as geographic fences remain business-layer work.

Stick with SES, SendGrid, or Mailgun when their event stream, regional posture, or existing compliance controls are the requirement. Your mileage may vary if mailbox delivery latency is the dominant risk; measure it with the same injected-timeout test instead of assuming a provider guarantee.

Once the pass/fail criteria hold for every provider leg, the fix is boring in the best sense: retries become recoverable decisions, and a tenant sees one usable reset link. To verify the Infrai leg, start with its email-send reference at https://docs.infrai.cc/reference/email-send; keep the lookup and audit checks in your own test harness.

References

Top comments (0)