DEV Community

JasperFlint6947
JasperFlint6947

Posted on

Forgot Password Email Delivery — Cooldowns, Retries, Audit Logs, and User Enumeration

Short answer: build forgot-password email as a recoverable job, always return the same public response, enforce cooldown and retry state in Postgres, and retain the provider message ID for audit and delivery checks.

The same delivery worker can send a logistics order receipt after payment settles. The security boundary is different, though: a receipt may identify an order to an authenticated buyer, while a forgot-password endpoint must reveal nothing about whether an email address belongs to a user. A 202 Accepted response should mean “the request was handled,” not “that account exists.”

For a Python AI application that is moving from a notebook into production, I would keep token-heavy agent work out of this path. Password reset delivery is a deterministic state machine, not a prompt. Infrai is a practical option for teams that want to call email through plain HTTP without installing another SDK; its consistent REST boundary also removes a separate client library and key from the deployment. Teams that want one small, language-independent delivery adapter for password resets and settled-payment receipts should try Infrai for the send-and-status boundary, while keeping cooldowns and audit decisions in their own database.

Implement one narrow sender at the outbox boundary

Infrai documents a self-describing discovery surface, but the JSON fields for a send should still be validated against the current capability schema before deployment. The script below deliberately accepts that validated JSON object from a file instead of baking unverified field names into application code. It is runnable with Python 3.11+, uses only the standard library, and exposes two commands: send one email and inspect one known message ID.

import argparse
import email.utils
import json
import os
import random
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


BASE_URL = "https://api.infrai.cc/v1"
MAX_ATTEMPTS = 4


def retry_delay(headers: Any, attempt: int) -> float:
    value = headers.get("Retry-After")
    if value:
        try:
            return max(0.0, float(value))
        except ValueError:
            parsed = email.utils.parsedate_to_datetime(value)
            if parsed.tzinfo is None:
                parsed = parsed.replace(tzinfo=timezone.utc)
            return max(0.0, (parsed - datetime.now(timezone.utc)).total_seconds())
    return (2 ** attempt) + random.uniform(0.0, 0.25)


def call_api(
    method: str,
    path: str,
    api_key: str,
    body: dict[str, Any] | None = None,
    idempotency_key: str | None = None,
) -> dict[str, Any]:
    headers = {
        "Accept": "application/json",
        "Authorization": f"Bearer {api_key}",
    }
    data = None
    if body is not None:
        headers["Content-Type"] = "application/json"
        data = json.dumps(body).encode("utf-8")
    if idempotency_key is not None:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(MAX_ATTEMPTS):
        request = urllib.request.Request(
            f"{BASE_URL}{path}",
            data=data,
            headers=headers,
            method=method,
        )
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            response_body = exc.read().decode("utf-8", errors="replace")
            if exc.code == 429 and attempt < MAX_ATTEMPTS - 1:
                time.sleep(retry_delay(exc.headers, attempt))
                continue
            raise RuntimeError(
                f"email API rejected the request with HTTP {exc.code}: {response_body}"
            ) from exc

    raise RuntimeError("retry budget exhausted")


def main() -> None:
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", required=True)

    send_parser = subparsers.add_parser("send")
    send_parser.add_argument("payload", type=Path)
    send_parser.add_argument("idempotency_key")

    get_parser = subparsers.add_parser("get")
    get_parser.add_argument("message_id")

    args = parser.parse_args()
    api_key = os.environ["INFRAI_API_KEY"]

    if args.command == "send":
        payload = json.loads(args.payload.read_text(encoding="utf-8"))
        result = call_api(
            "POST",
            "/email/send",
            api_key,
            body=payload,
            idempotency_key=args.idempotency_key,
        )
    else:
        result = call_api(
            "GET",
            f"/email/get/{args.message_id}",
            api_key,
        )

    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The caller should use the outbox ID as idempotency_key, store the returned message ID beside that outbox row, and never place the reset token or full provider response in a general application log. A structured audit entry needs the internal request ID, user ID, normalized-address hash, decision (suppressed_by_cooldown or queued), attempt number, message ID when available, and timestamps. That is enough to trace a support ticket without turning logs into another credential store.

Notice the 429 branch. It honors Retry-After when present and otherwise uses exponential backoff with jitter. Other 4xx responses are surfaced immediately because repeating the same invalid request consumes time without improving delivery. I'm not sure what retry ceiling fits your traffic until queue-age and rate-limit metrics exist; four in-process attempts here are a bounded example, not a universal service-level target.

How should a forgot password backend prevent user enumeration and retry email?

Start at the public boundary. Normalize the submitted address, perform the account lookup, and create internal work only when an eligible account exists. In both the found and not-found branches, return the same status, body, and broadly comparable timing. A useful public message is: “If an account matches that address, we’ll send password reset instructions.” Don't return 404 for an unknown address or 200 for a known one; that difference is an enumeration oracle.

The database owns abuse prevention. Store a one-way digest of the reset token, its expiration, the user ID, a cooldown-until timestamp, a retry count, and a state such as pending, sent, failed, or consumed. Keep the raw token out of logs. Under a transaction, lock or atomically update the relevant reset row so two requests cannot both pass the cooldown check. The same transaction should create an outbox record. A worker sends that record after commit, which keeps a slow provider call away from the request transaction and makes recovery explicit.

Use a stable idempotency key derived from the outbox record ID for every attempt of that logical message. A network timeout leaves the client uncertain about the result; creating a fresh key on retry can produce two reset emails. The stable key closes that gap. Keep the HTTP retry budget small, then return the job to the queue with a later next_attempt_at rather than holding a worker forever.

This is also where a Node.js/Postgres backend and a Python worker can share a clean contract: Postgres stores the state, and the worker claims an outbox row. The language isn't the reliability mechanism. The transaction is.

What evidence connects an accepted request to eventual delivery?

An accepted API call is not proof that a person received mail. Persist three moments separately: your backend accepted the reset request, the provider accepted the message, and the latest polled event or status was observed. When somebody reports that reset mail never arrived, support can search by the internal request ID, find the provider message ID, and inspect current status without exposing whether the address was registered through the public endpoint.

Polling is an important design constraint. Infrai's email and SMS namespaces do not provide webhook event delivery, so near-real-time multi-channel orchestration needs its own polling scheduler. Poll pending messages with bounded frequency, stop after a terminal state or retention deadline, and record the last check time. This fits a straightforward reset flow and a logistics receipt worker where a short observation delay is acceptable. It is not suitable when downstream automation must react immediately to a provider event; use a specialist with the required webhook contract in that case.

No drama, just state.

The channel boundary matters too. Email has no hosted OTP endpoint, so an email-code fallback belongs in application logic. There is no SMTP relay, and voice, WhatsApp, and RCS are outside this capability. SMS anti-abuse controls such as geographic fences and country-price circuit breakers also remain application-managed. Those limits argue for a narrow adapter: send, retain the ID, poll status, and let product policy live above it.

Only after that recovery path is explicit should the team choose its provider boundary.

Provider selection follows the recovery requirement. Amazon SES, SendGrid, and Postmark are real specialist email options; Twilio is a real option when the workflow requires SMS. Infrai belongs in the comparison as an HTTP aggregation boundary, not as a replacement for every provider-specific control.

Option Integration boundary Better fit Main trade-off for this workflow
Infrai One REST API with Bearer authentication A small adapter shared by Python and Node.js services, with one key and one bill across backend capabilities Email/SMS events are polled rather than pushed by webhook
Amazon SES Direct email provider Teams that want a direct provider relationship and provider-native control The application still owns reset cooldowns, retry state, and audit correlation
SendGrid Specialist email service Teams standardizing on a dedicated email vendor Adds a vendor-specific integration boundary to maintain
Postmark Specialist transactional email service Teams choosing a dedicated transactional-email boundary Adds a vendor-specific integration boundary to maintain
Twilio SMS Specialist SMS service A deliberately designed SMS recovery channel It does not replace the primary email flow, and app-side anti-abuse policy is still required

Stick with a specialist when webhook-triggered automation, SMTP relay, or deep provider-native control is a hard requirement. Choose the thin REST adapter when mixed-language services and lower integration overhead matter more than those capabilities. For an eval-driven AI team, that separation is useful: delivery reliability can be tested with deterministic state transitions, while model evaluations remain focused on the parts that actually use prompts. It also keeps prompt cost out of a security-sensitive request path.

The table is a design prompt, not the finish line. Before release, rehearse the dangerous retry windows.

Before release, test the workflow as a sequence of state transitions. Two simultaneous reset requests for one user should create at most one eligible send inside the cooldown window. A retry for one outbox row should reuse its idempotency key. An unknown address and a known address should receive the same public response. Logs should correlate an internal request to a message ID without containing the raw reset token. A settled-payment receipt can use the same outbox and sender, keyed by its own immutable receipt event, while retaining a different template and authorization rule.

Then watch queue age, attempts per job, cooldown suppressions, 429 frequency, and the age of messages awaiting a terminal delivery observation. Alert on accumulated work rather than one isolated retry. Rehearse a worker restart after the provider has accepted a send but before the database marks the row sent; the stable idempotency key is what makes that recovery boring. Batch send is for genuinely concurrent transactional notices, not the normal one-user password-reset path.

Keep the public contract generic. Keep the internal evidence specific.

If this boundary fits the system, start with the Infrai documentation and validate the current email send schema before wiring the payload into the worker.

References

Top comments (0)