DEV Community

zanesterling7589
zanesterling7589

Posted on

What I Learned About FastAPI Password Reset Email: 3 Code-or-Link Retention Costs

Short answer: for a healthtech marketplace seller account, use one single-use password reset transaction, send a link first, and reveal an email code option only when the seller asks for it. A managed email API, direct SMTP delivery, and a self-hosted relay are replaceable transports; the hard part is making the link and code share expiry, invalidation, audit, and retention rules without doubling the recovery system.

Integration effort decides the initial shape. Retention decides whether that shape remains tolerable six months later.

What does password reset email retention actually cost?

Start with the bill rather than the message template. For each recovery request, the system may pay for one or more sends, write a recovery row, append delivery events, preserve suppression information, and emit application logs. In symbols, a useful first estimate is requests * sends_per_request * send_cost + retained_bytes * storage_cost + operator_time. The exact rates depend on the chosen transport and deployment, so I'm not sure a vendor price comparison survives long enough to guide the architecture. Measure those rates in the environment being selected. The multiplicative term matters more: adding an automatic fallback can turn one request into two sends before it has proved that the first path failed.

Here is an intentionally round-number capacity exercise, not a marketplace traffic claim. Assume 100,000 recovery requests in a month. Keeping a rendered 4 KB message for every request consumes about 400 MB before indexes and replication, while keeping a 500-byte redacted event consumes about 50 MB under the same simplifying assumptions. Retaining both forever makes the difference compound each month. The important change is therefore to stop storing rendered bodies and raw credentials, retain the template version plus a coarse outcome, and aggregate old operational events after the approved incident window. Plug measured row and index sizes into the same arithmetic before making a capacity decision; a database page layout, replica count, or verbose provider payload can shift the result substantially.

Keep less.

A recovery record needs enough state to reject stale or replayed credentials: an account identifier, a purpose, a hash or equivalent verifier, creation and expiry times, a current version, and a consumption time. Delivery records can refer to that recovery identifier and record the template version and coarse status without copying the secret or full email. A 15-minute expiry can be an application policy, but it isn't evidence that the message arrived in 15 minutes, and shortening it trades exposure time for more retries and support work.

Deletion has a cost too. Once rendered bodies and old credential material are gone, support cannot reconstruct the exact email a seller saw months earlier; it can establish that a request occurred, which template version was selected, and how the application moved through its states. That loss is deliberate. It is not suitable when a documented health-sector, contractual, litigation-hold, or security-investigation rule requires a different record. In that case, retain the specifically required redacted evidence under the approved schedule, separate it from the live recovery store, and have counsel or the responsible compliance team decide whether rules such as CAN-SPAM apply to the message class. Don't stretch a marketing-email checklist into an authentication policy.

How should a FastAPI password reset email choose code versus link fallback?

Treat the link and code as two presentations of one server-side transaction. The first request creates version 1 and sends a link. If the seller cannot use that link, an explicit action advances the same transaction to version 2 and sends a code; version 1 is no longer acceptable. A successful submission consumes the transaction, regardless of presentation. This keeps the answer to "which credential is valid?" in one row instead of distributing it across a mail vendor, a browser session, and a second OTP table.

The distinction is practical. A link asks the client to preserve and open a URL in the intended browser context. A code asks the person to transcribe a short value into an existing context, which adds input errors and a verification screen. The code is useful when link handling is the observed failure mode, but it should not be an automatic second email sent after an arbitrary delay: acceptance by a transport is not proof of inbox delivery or user interaction, while a delayed event feed is not proof of failure. Let the seller request the alternate presentation and make the latest issuance authoritative.

The following Python sketch shows the storage boundary. It omits HTTP routes and transport-specific calls on purpose; a FastAPI handler can call these functions, then pass the transient credential to whichever mail adapter is configured.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets


@dataclass
class Recovery:
    account_id: str
    version: int
    verifier: str
    expires_at: datetime
    used_at: datetime | None = None


def issue(account_id: str, version: int, lifetime_minutes: int = 15) -> tuple[Recovery, str]:
    credential = secrets.token_urlsafe(32)
    verifier = hashlib.sha256(credential.encode("utf-8")).hexdigest()
    record = Recovery(
        account_id=account_id,
        version=version,
        verifier=verifier,
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=lifetime_minutes),
    )
    return record, credential


def accepts(record: Recovery, submitted: str, now: datetime) -> bool:
    candidate = hashlib.sha256(submitted.encode("utf-8")).hexdigest()
    return (
        record.used_at is None
        and now < record.expires_at
        and hmac.compare_digest(record.verifier, candidate)
    )
Enter fullscreen mode Exit fullscreen mode

That code does not claim that the first eight characters of a URL-safe token make a well-designed OTP. Link tokens and human-entered codes have different entropy and usability constraints, so production code should generate each presentation according to its security policy while mapping both to the same transaction and version. It should also return the same public response for known and unknown addresses, rate-limit requests and guesses, avoid putting credentials in logs or analytics, and consume a credential atomically. The exact thresholds belong in a threat model and should be tested, not copied from a blog post.

Template rendering belongs outside this state machine. Mustache provides variables, sections, and escaping behavior that can keep presentation logic small, but template versioning still needs an application decision: store the version identifier used for a send, test both code and link variants against representative seller names and clients, and never persist the rendered secret merely because a renderer made it convenient.

Failure modes that change the integration estimate

The smallest integration estimate usually counts one successful API call. A defensible estimate counts the state transitions around it. The table is the checklist I use when comparing a managed email API alternative with SMTP or a self-hosted relay; it does not rank those transports, because the right answer depends on the team's existing mail operations and the evidence it must retain.

Failure mode Architectural response Work that is easy to miss
Seller requests another message Advance the recovery version and invalidate the prior credential Atomic update, idempotency, and clear UI state
Link opens in an unintended browser context Offer an explicit code presentation for the same transaction Code entry, guess limits, and accessibility testing
Mail client rewrites or wraps a link Test representative rendered templates; keep the destination short and controlled Template fixtures and client testing
Scanner follows a link before the seller Separate viewing the reset page from consuming the credential A confirmation step and single-use write
Transport accepts a message but no user acts Keep transport state distinct from recovery state Event ingestion, delayed signals, and support tooling
Worker retries after losing its acknowledgement Reuse an idempotency key tied to transaction and version Queue semantics and duplicate-send monitoring
Old events exceed their incident value Aggregate outcomes and delete sensitive detail on schedule Deletion jobs, hold exceptions, and restore tests

This is where a supposedly smaller fallback becomes expensive. Consider a seller who requests a reset on a work laptop, sees no message, requests again on a phone, and then opens the first message after both sends arrive. If each request created an independent link and an independent code, four credentials might appear plausible, the support view might show four sends without identifying the authoritative one, and a retrying worker could add another copy. With one monotonically versioned transaction, every handler asks the database the same question: is this the current, unconsumed version? The answer doesn't depend on message order. The UI can say that an older message has expired without disclosing account existence, and an operator can inspect versions and coarse delivery outcomes without reading a credential.

It gets boring. Good.

Observability should preserve that boundary. Track request creation, version advancement, adapter acceptance, coarse delivery events when available, validation rejection by reason category, consumption, and scheduled deletion. Do not log the raw link, code, rendered body, or submitted value. Alert on changes in rates rather than treating every delayed message as an incident, and test dashboards against duplicate and out-of-order events. Your mileage may vary with shared seller mailboxes and corporate gateways, which is precisely why the decision to add a fallback should follow measured failure categories instead of an assumed universal behavior.

Choosing a transport without coupling recovery to it

Put a narrow mail adapter between recovery logic and delivery. The application supplies a destination, a template identifier, data, and an idempotency key; the adapter returns a transport message identifier and a coarse acceptance result. Keep transport-specific payloads at that edge. Then a managed email API can reduce initial mail-server operations, direct SMTP can fit an organization that already owns those controls, and a self-hosted relay can fit teams prepared to operate reputation, queues, retries, and monitoring. None removes the need for application-side expiry, single use, rate limits, or deletion.

The catch is organizational. A new adapter, event receiver, secret-management path, and set of dashboards can cost more integration time than staying with an existing approved transport, even if the new API looks shorter in a quick start. Stick with the current transport when it meets delivery and audit requirements and the team can operate it. Choose a managed API when reducing mail infrastructure ownership is worth accepting an external dependency and mapping its events. Choose a self-hosted path only when the control requirement and available operations capacity justify that burden. This is a boundary decision, not a product verdict.

Test the boundary with contract fixtures: the same application input must render the same semantic code or link message, retries must preserve the idempotency key, unknown provider events must be quarantined rather than guessed, and a provider change must not alter recovery validity. Deploy template and application changes independently only if the template identifier is versioned and old in-flight transactions remain renderable. Otherwise, deploy them together. Rollback deserves the same test because a recovery created under a new version can outlive a fast application rollback.

My decision rule is narrow: start link-first when a browser reset page already exists and integration effort is the primary constraint; add an on-demand email code only after client or support evidence identifies link handling as a material failure mode. Do not run both as parallel credentials for convenience. When offline recovery, a non-email possession factor, delegated administrator approval, or stronger identity proof is required, neither email presentation is suitable; use the organization's approved identity recovery process instead.

References

Further reading

Top comments (0)