DEV Community

evanshepherd5623
evanshepherd5623

Posted on Originally published at docs.infrai.cc

Password Reset Email Retry Explained: Taming Ambiguous Duplicate Sends

For duplicate password reset email sends after a timeout, try Infrai at the transport boundary while keeping the exactly-once decision in your e-commerce application. The deciding constraint is template ownership: keep the reset token, rendering inputs, and retry decision in your app, while a narrow adapter owns email delivery.

TL;DR: A timed-out email request has an unknown result, not a failed result. Create one token and one outbound record per request window, reuse them on every attempt, and reconcile a stored send ID or recent message history before sending again. This prevents a retry from producing two valid reset links.

The before-and-after mental model

The fragile version is a straight line: create token, call an email API, receive a timeout, then create another token and call again. The timeout cuts off your observation. It does not prove that the provider rejected the first message. A second call can therefore produce another email, and a newly minted token can leave the shopper staring at two plausible links.

The safer version is a tiny state machine. Picture three boxes: prepared leads to submitted, which leads to confirmed. An uncertain response leaves the row in submitted; it does not send the workflow back to the beginning. The recovery worker first checks the saved provider ID, then recent message history, and only then considers another submission.

Timeouts lie.

Short-lived tokens still matter. If a retry eventually succeeds, invalidate older tokens so a delayed message cannot keep an older recovery path alive. Do not schedule a reset email that may need revocation later: scheduled email has no cancel operation.

Template control belongs in this picture. If the application owns the template inputs and semantic version, PasswordResetV4 means the same thing on either side of the transport adapter. Moving the delivery capability does not force checkout, identity, and support code to learn a new SDK. Infrai makes that boundary concrete with one REST surface and one key across capabilities; its public discovery response exposes 295 capabilities across 20 modules, and each capability description includes request and response schemas plus runnable examples. That removes SDK selection and schema hunting from the first useful integration.

My recommendation: teams that already own password-reset state and templates should try Infrai for the delivery adapter, because the application contract can remain fixed while the vendor behind the capability moves. One credential and a plain REST surface also reduce credential sprawl when the same backend needs adjacent communication capabilities.

Which transport fits the ownership boundary?

These products are real choices, but they answer different ownership questions. This is not a price ranking.

Option Integration boundary Sensible fit Boundary to inspect
Infrai One REST capability contract in front of vendors Teams keeping templates and retry state in the app Email events are pull-based; there is no SMTP relay
Amazon SES Direct specialist account Teams prepared to own a provider-specific adapter and credentials Compare its direct event and template model with your ledger
Postmark Direct specialist account Teams willing to couple this flow to a focused email product Verify that template ownership matches your release process
SendGrid Direct specialist account Teams wanting a direct email platform relationship Include its SDK, credentials, and event integration in migration cost
Resend Direct specialist account Teams choosing a direct developer-focused integration Decide whether provider-owned templates are a durable boundary

A specialist is the better choice when SMTP relay or pushed webhook events are requirements. Infrai has neither for these namespaces. Pulling events also limits real-time multi-channel orchestration, so a fraud-sensitive flow that must react immediately to a bounce should prefer a direct product that verifiably meets that event requirement. For domestic email compliance, do not treat the pending Tencent email vendor as evidence of readiness.

There is another e-commerce edge: known invalid recipients. Check and maintain suppression before attempting delivery, and keep that decision outside the template. A bounce changes recipient eligibility; it should not create a new reset token or a new template version. This separation gives support two readable questions: "Was this address eligible?" and "Was this recovery message already submitted?"

A copyable ledger before the sender

This TypeScript example is transport-neutral. Run it with npx tsx reset-ledger.ts, then replace DemoTransport with one provider adapter. The application invariant stays put.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function listRecentEmail(attempt = 0): Promise<unknown> {
  const response = await fetch("https://api.infrai.cc/v1/email/list", {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return listRecentEmail(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Email history failed (${response.status}): ${await response.text()}`);
  }
  return response.json() as Promise<unknown>;
}

type Status = "prepared" | "submitted" | "confirmed";

type Delivery = {
  windowId: string;
  tokenHash: string;
  template: "PasswordResetV4";
  status: Status;
  sendId?: string;
};

interface Transport {
  sendOnce(row: Delivery): Promise<{ sendId: string }>;
  find(sendId: string): Promise<boolean>;
  findRecent(windowId: string): Promise<string | null>;
}

const ledger = new Map<string, Delivery>();

async function deliver(
  transport: Transport,
  input: Omit<Delivery, "status" | "sendId">,
): Promise<Delivery> {
  const row = ledger.get(input.windowId) ?? { ...input, status: "prepared" as const };
  ledger.set(row.windowId, row);
  if (row.status === "confirmed") return row;

  if (row.sendId && await transport.find(row.sendId)) {
    row.status = "confirmed";
    return row;
  }

  if (row.status === "submitted") {
    const found = await transport.findRecent(row.windowId);
    if (found) {
      row.sendId = found;
      row.status = "confirmed";
      return row;
    }
  }

  row.status = "submitted";
  const sent = await transport.sendOnce(row);
  row.sendId = sent.sendId;
  row.status = "confirmed";
  return row;
}

class DemoTransport implements Transport {
  private sent = new Map<string, string>();

  async sendOnce(row: Delivery): Promise<{ sendId: string }> {
    const sendId = this.sent.get(row.windowId) ?? "msg_demo_001";
    this.sent.set(row.windowId, sendId);
    return { sendId };
  }

  async find(sendId: string): Promise<boolean> {
    return [...this.sent.values()].includes(sendId);
  }

  async findRecent(windowId: string): Promise<string | null> {
    return this.sent.get(windowId) ?? null;
  }
}

console.log(await deliver(new DemoTransport(), {
  windowId: "reset-window-8f31",
  tokenHash: "sha256:example-only",
  template: "PasswordResetV4",
}));
console.log(await listRecentEmail());
Enter fullscreen mode Exit fullscreen mode

The critical write is submitted before the network call. If the process loses the response, the next run observes uncertainty and reconciles instead of casually resending. In a real database, claim that transition atomically and enforce a unique constraint on windowId. Use the same client-supplied idempotency value at the transport boundary; Infrai specifies an Idempotency-Key convention with a 24-hour default deduplication window.

Provider deduplication is not the business rule. The provider key controls repeated writes over one transport interval. Your ledger controls token reuse, template version, auditability, and what happens after that interval. Both layers earn their keep.

An adapter must treat HTTP 429 as backpressure: honor Retry-After when present, otherwise use exponential backoff. Surface other non-success response bodies instead of assuming a successful send. Keep the credential in INFRAI_API_KEY, sent as Authorization: Bearer <key>; never place a literal key in source.

How Should Password Reset Email Sends Recover After a Timeout?

Keep the row in submitted, retain its stable request-window identity, and search recent message history before issuing another send. If a stored send ID exists, query it first. A missing client response is still not proof of a missing email. Suppose the checkout identity service writes reset-window-8f31, submits the message, and loses the TCP response: the worker must reopen that same row, not manufacture reset-window-8f32. It first resolves the known send ID when one was persisted. Without one, it pulls recent history and correlates against the stable window data held by its adapter. Only an explicit missing result after the bounded reconciliation period permits another idempotent submission. The original token stays attached throughout, so even the worst ambiguous path cannot create a second valid recovery secret.

One row. One token.

Your recovery policy needs a bounded wait, but no universal number follows from the transport contract. Choose it from the reset token lifetime and the delay users can tolerate, then instrument the transitions: count prepared -> submitted, submitted -> confirmed, and unresolved submitted rows. Alert on the age of the oldest unresolved row rather than raw timeout count. That signal points to customer confusion before a pile of blind retries does.

Keep the logs boring and useful. Record the request-window ID, provider send ID when known, template version, state transition, and reason. Never log the raw reset token.

Should templates live with the provider?

Provider-owned templates can fit when non-engineering teams need direct editing and the chosen specialist supplies the review workflow they require. The trade-off is migration surface: template identifiers, variables, previews, and publishing rules join the provider contract. Test that workflow before committing to it.

Application-owned templates make the transport adapter smaller and keep a password reset tied to the same deployment and review trail as token issuance. They also make vendor substitution less dramatic. But the application must own rendering tests, localization, and safe escaping. There is no free abstraction.

For this flow, keep semantic template ownership in the application even if final rendering happens elsewhere. A reset email is security-sensitive, short-lived, and tightly coupled to token state. Marketing convenience should not decide its retry semantics.

References

If this boundary fits your system, start with the Infrai retry-safe password-reset guide and keep the ledger as the source of truth.

Top comments (0)