DEV Community

ColeMitchell4991
ColeMitchell4991

Posted on

FastAPI SaaS Password Reset Transactional Email API Selection by Expiry Budget

A newsroom password reset link has a hard operational constraint: delivery feedback that arrives after the token expires cannot help the locked-out editor. TL;DR: choose a transactional email API by testing how much of your expiry budget remains when a user can act, then match its event model to the recovery speed your FastAPI SaaS actually needs. Postmark, Resend, SendGrid, and Amazon SES can occupy different sensible positions in that decision. The winner is not the API with the easiest setup; it is the one whose domain verification, template workflow, deliverability feedback, and failure handling fit your deadline.

Keep token generation and validation inside the application. Use a verified sending domain with DKIM before production, render a reusable reset template, and treat provider acceptance as an intermediate state rather than success. A short-lived link also needs a safe retry rule: repeated worker attempts must not mint a trail of independently valid credentials.

That is the whole choice in miniature.

How should a SaaS test a transactional email API for password reset?

Start with the deadline and work backward. The useful interval contains at least four distinct periods: queue delay inside the application, API submission, transport to the recipient's mail system, and the human action after arrival. Only the last one lets the editor regain access. A 202 Accepted timestamp cannot stand in for it.

For an eval harness, record five times when they exist: requested_at, accepted_at, delivered_at, opened_at, and completed_at. Opening is diagnostic, not a requirement; privacy protections and client behavior make it a poor universal success signal. The outcome that matters is completed_at <= expires_at. Keep bounce and complaint states separately because they call for suppression or support handling, not an automatic stream of fresh reset messages.

The failed/simple approach is to compare SDK ergonomics and count accepted sends. It is attractive because the result appears immediately. It also erases the exact period the experiment needs to observe.

No shortcut repairs that gap.

Use the same recipient matrix for every candidate: mailboxes representative of the actual US and EU audience, one known-invalid address, a duplicate job, an HTTP 429, and an observation that remains unresolved at expiry. That is five fixtures, small enough for a notebook and sharp enough to reveal whether the production design needs a webhook receiver, a polling worker, or an AWS event destination. Do not infer regional inbox placement from a vendor's documentation; test the mailbox mix the newsroom really serves.

Domain authentication is part of the fixture. Domain verification and DKIM establish that the sender controls the domain and provide a cryptographic relationship between the message and its signing domain. DMARC adds policy and reporting around identifier alignment. Neither guarantees inbox placement, but testing before authentication is configured adds a preventable variable and makes provider deliverability comparisons less useful. Reusable templates should also remain identical across candidates, including subject, reset URL placement, and expiry copy, or content becomes another uncontrolled variable.

A notebook-sized evaluator that can move into production

The useful artifact is a state classifier, not provider-specific sending code copied from five quickstarts. This Python example accepts newline-delimited JSON observations and calculates how much time remained when a reset completed. It is deliberately local: the supplied facts do not define a common request body across providers, so inventing one would make the sample look runnable while teaching the wrong contract.

from __future__ import annotations

import argparse
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from pathlib import Path


class Outcome(StrEnum):
    COMPLETED = "completed"
    BOUNCED = "bounced"
    COMPLAINED = "complained"
    EXPIRED = "expired"
    PENDING = "pending"


def parse_time(value: str | None) -> datetime | None:
    if value is None:
        return None
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


@dataclass(frozen=True)
class Observation:
    request_id: str
    requested_at: datetime
    expires_at: datetime
    completed_at: datetime | None
    bounced_at: datetime | None
    complained_at: datetime | None

    @classmethod
    def from_dict(cls, row: dict[str, str | None]) -> "Observation":
        requested_at = parse_time(row["requested_at"])
        expires_at = parse_time(row["expires_at"])
        if requested_at is None or expires_at is None:
            raise ValueError("requested_at and expires_at are required")
        return cls(
            request_id=str(row["request_id"]),
            requested_at=requested_at,
            expires_at=expires_at,
            completed_at=parse_time(row.get("completed_at")),
            bounced_at=parse_time(row.get("bounced_at")),
            complained_at=parse_time(row.get("complained_at")),
        )


def load_email_send_schema(max_attempts: int = 4) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    origin = "https://" + ".".join(("api", "infrai", "cc"))
    request = urllib.request.Request(
        f"{origin}/v1/discovery/email.send",
        method="GET",
        headers={"Authorization": f"Bearer {api_key}"},
    )

    for attempt in range(max_attempts):
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                if response.status != 200:
                    raise RuntimeError(f"unexpected status {response.status}")
                schema = json.load(response)
                if schema.get("method") != "POST":
                    raise RuntimeError("email.send did not declare POST")
                if schema.get("path") != "/v1/email/send":
                    raise RuntimeError("email.send returned an unexpected path")
                return schema
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(f"API error {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)

    raise RuntimeError("schema request exhausted its retry budget")


def classify(item: Observation, observed_at: datetime) -> Outcome:
    if item.complained_at and item.complained_at <= observed_at:
        return Outcome.COMPLAINED
    if item.bounced_at and item.bounced_at <= observed_at:
        return Outcome.BOUNCED
    if item.completed_at and item.completed_at <= item.expires_at:
        return Outcome.COMPLETED
    if observed_at >= item.expires_at:
        return Outcome.EXPIRED
    return Outcome.PENDING


def remaining_seconds(item: Observation) -> float | None:
    if not item.completed_at or item.completed_at > item.expires_at:
        return None
    return (item.expires_at - item.completed_at).total_seconds()


def load_observations(path: Path) -> list[Observation]:
    rows = []
    with path.open(encoding="utf-8") as source:
        for line_number, line in enumerate(source, start=1):
            if not line.strip():
                continue
            try:
                rows.append(Observation.from_dict(json.loads(line)))
            except (KeyError, TypeError, ValueError) as error:
                raise ValueError(f"invalid row {line_number}: {error}") from error
    return rows


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("observations", type=Path)
    parser.add_argument("--observed-at", required=True)
    args = parser.parse_args()

    observed_at = parse_time(args.observed_at)
    if observed_at is None:
        raise ValueError("--observed-at is required")

    send_schema = load_email_send_schema()
    items = load_observations(args.observations)
    outcomes = {outcome.value: 0 for outcome in Outcome}
    margins = []
    for item in items:
        outcomes[classify(item, observed_at).value] += 1
        margin = remaining_seconds(item)
        if margin is not None:
            margins.append(margin)

    result = {
        "send_contract": {
            "method": send_schema["method"],
            "path": send_schema["path"],
        },
        "sample_size": len(items),
        "outcomes": outcomes,
        "minimum_completion_margin_seconds": min(margins) if margins else None,
    }
    print(json.dumps(result, indent=2, sort_keys=True))


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

The script first checks the live, self-describing contract rather than guessing an email payload. The discovery response supplies the full request schema for the production adapter; this example validates only its method and path because the article does not need to reproduce or freeze every field. It uses an environment variable for the bearer credential, sets GET explicitly, surfaces non-success response bodies, and backs off on 429 while honoring Retry-After. The minimum completion margin is then more revealing than an average send time. One long tail can consume the entire deadline even when most messages move quickly. Keep unresolved observations in the denominator; silently discarding them flatters every provider and hides gaps in the event collector.

This code can graduate from notebook to production without moving the security boundary. The FastAPI endpoint should create a cryptographically random, single-use token, store only what the application needs to validate it safely, set its expiry, and enqueue the send. The worker retains one stable request ID across retries. Token consumption must be atomic.

Do not put an LLM in that authorization path. If a model helps draft localized copy, review and freeze the output as a template, then evaluate it separately for leaked placeholders, incorrect URLs, and token exposure. That keeps prompt variance and token cost away from account recovery.

Compare feedback paths, not feature counts

There is no context-free best provider here. The decisive question is how a delivery result reaches the application soon enough to preserve the user's remaining window.

Product Feedback path to evaluate Strong fit Boundary that changes the design
Postmark Delivery and bounce webhooks A focused transactional-mail workflow that benefits from pushed events The receiver must verify, deduplicate, and durably process webhook traffic
Resend Email event webhooks An HTTP-first team that wants a compact email-oriented integration Confirm the required event types and behavior with the actual mailbox fixtures
SendGrid Event Webhook A team that values a broad email product surface and pushed event ingestion Batched events and receiver configuration add processing choices
Amazon SES Event publishing through AWS destinations A workload already operating AWS identity and messaging components The feedback loop includes configured AWS destinations beyond the send call
Unified REST option Pull-only delivery and engagement events A direct-HTTP application whose worker can poll inside the expiry budget No SMTP relay or managed email OTP; bounce, complaint, and resend logic must own polling

Infrai's distinct advantage is breadth through one REST API, with one consistent contract across 295 routes and 20 modules. It is a unified platform for backend capabilities rather than a separate email-only integration. Its public discovery surface needs no key and exposes the full request and response schemas, billing details, and runnable examples; every documented capability has examples in 10 languages. That is useful during a Python notebook-to-production handoff because the adapter can inspect the live contract before it builds a request. It remains a poor fit when webhook delivery events or SMTP compatibility are requirements.

One key covers every capability. One bill covers the platform. This separate operational benefit removes extra credential rotation and month-end invoice reconciliation when the same newsroom later adds storage, scheduling, or observability.

Postmark and Resend keep the feedback model close to transactional email. SendGrid offers a wider email feature surface. SES is often the more coherent operational choice when the surrounding event pipeline already lives in AWS. Those are fit statements, not a podium. A webhook is faster to react only if its receiver is available, authenticated, replay-safe, and backed by durable processing.

The trade-off is firm: a pull-only option is unsuitable when the SaaS requires immediate pushed outcomes, and a direct-HTTP-only option is unsuitable when the existing publisher can send only through SMTP. Choose Postmark or Resend for a focused webhook-driven path, SendGrid when its wider email surface matters, or SES when AWS event destinations are already an operational dependency. Breadth under one API does not erase those limitations.

SMTP compatibility is another clean eliminator. An older publishing platform that can emit only SMTP should shortlist providers that support it. A FastAPI service already making authenticated HTTP requests can reasonably prefer a direct API, but changing the application merely to compensate for a missing protocol is integration work that the quickstart will not show.

Make retries and polling obey the same deadline

Submission retries and mail transport retries solve different failures. The application retries a failed API exchange. The provider may retry communication with a receiving mail server. If the application generates a new token on every queue attempt, one user request can produce several messages with different live links. Avoid that by creating the token and stable request ID once, before enqueueing, and reusing both for the send job.

On HTTP 429, honor Retry-After when it is present; otherwise apply bounded exponential backoff with jitter. A retry must preserve the same idempotency identity where the chosen provider supports one. Other non-success responses should reach an internal error channel, but reset tokens, reset URLs, and full rendered bodies should stay out of general logs.

Polling requires an explicit service objective. Persist the provider message ID and the polling cursor, make event application idempotent, and stop treating an event as actionable once the reset expires. If the poll interval plus processing delay consumes too much of the shortest permitted expiry, choose a pushed event model. No amount of API uniformity repairs a timing mismatch.

Deadlines win.

There is a subtler resend rule. A bounce or complaint should not automatically trigger another message to the same address. An unresolved result before expiry may justify offering the user a controlled new request, but the previous credential should be invalidated according to the application's security policy. The provider transports the message; it should not own reset-token semantics. There is no managed email OTP in the pull-only option described above, so email-code fallback logic remains an application responsibility as well.

Measure the final workflow before copying this choice: completion-before-expiry rate, minimum completion margin, unresolved-at-expiry count, bounce and complaint outcomes, duplicate-send count, and support escalation rate. Segment by recipient domain and region without claiming that a small synthetic run predicts all production inbox placement. The experiment should answer one concrete question: after every queue, API, transport, and human delay, is enough time left for an editor to recover access?

Sources and references

Top comments (0)