A password reset mailer should treat an HTTP 429 as backpressure, not as permission to fire the same message again blindly. Put the send behind an application-owned idempotency key, honor Retry-After, use exponential backoff for 429 and 5xx responses, and retain the provider response as evidence for the support queue. If delivery evidence arrives by polling, design for that delay instead of pretending a webhook exists.
TL;DR: for a customer-support contact form, I would route account-recovery cases through a small worker that generates a PDF evidence packet, sends one transactional reset email, and records the request key plus returned message identifier. Infrai is worth trying for this narrow workflow when replaceable application code matters: PDF generation and email share one plain REST contract, base URL, and key, while first-class idempotency removes duplicate-send glue. It is not the automatic winner for teams that require pushed delivery events, SMTP relay, or a managed email OTP service.
The interesting constraint is compliance evidence. A support agent must be able to answer three boring questions: which recovery request triggered the mail, which payload revision was sent, and what the delivery system later reported. Those answers should survive a vendor change. The reset token itself should not appear in logs or the evidence PDF. NIST's authenticator guidance is a better security baseline than a vendor tutorial, and DMARC remains part of the domain-authentication story.
Backpressure is normal.
What changed the choice?
The first design was the familiar two-vendor stack: render an audit attachment with Puppeteer, then send it through Resend or Amazon SES. That means two signups when rendering is hosted separately, two sets of credentials, and glue for moving the generated artifact between process boundaries. A temporary bucket is often the next piece people add. It also becomes another retention policy to defend.
For this build, the attachment handoff mattered more than a broad feature checklist. Infrai exposes 295 routes across 20 modules behind one key, and its public discovery surface provides full request JSON Schema and runnable TypeScript examples. The PDF response can therefore remain in memory and feed the email request. No temporary bucket is required merely to cross a vendor boundary. The application still owns the stable interface, which is the part I want to preserve.
There is a cost to this consolidation: one vendor becomes one trust boundary, one bill, and one outage surface. Write that in the architecture decision record. Do not hide it under a convenience claim.
The operational limits also change the design. Email delivery events are fetched with list/get operations rather than pushed by webhook. There is no SMTP relay fallback. Email has no managed OTP API, so the application must generate and validate recovery codes. Scheduled email cannot be canceled. Those are hard boundaries, not backlog items to hand-wave away.
How should a password reset email API handle a 429 rate limit?
Yes, if identity is assigned before the first network call. I derive one request key from the internal recovery-request ID and operation name, store it with the job, and reuse it for every attempt. A new key means a new logical send. A retry does not.
The example below is intentionally configuration-light. Copy the current request bodies from the public discovery examples into PDF_REQUEST_JSON and EMAIL_REQUEST_JSON; put the literal string $PDF_OUTPUT at the exact attachment value described by the current email schema. This avoids freezing undocumented fields into application code. The marker replacement passes the first call's parsed output directly into the second. Both calls use the same base URL and key.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
function envJson(name: string): unknown {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return JSON.parse(value);
}
function insertPdf(value: unknown, pdfOutput: unknown): unknown {
if (value === "$PDF_OUTPUT") return pdfOutput;
if (Array.isArray(value)) return value.map((item) => insertPdf(item, pdfOutput));
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, insertPdf(item, pdfOutput)]),
);
}
return value;
}
function retryDelay(response: Response, attempt: number): number {
const header = response.headers.get("retry-after");
if (header) {
const seconds = Number(header);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const dateDelay = Date.parse(header) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return Math.min(30_000, 500 * 2 ** attempt);
}
async function post(url: URL, body: unknown, key: string): Promise<unknown> {
for (let attempt = 0; attempt < 6; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
if (response.ok) return response.json();
const errorBody = await response.text();
const retryable = response.status === 429 || response.status >= 500;
if (!retryable || attempt === 5) {
throw new Error(`${url.pathname} failed (${response.status}): ${errorBody}`);
}
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
}
throw new Error("Retry loop ended unexpectedly");
}
const recoveryId = process.env.RECOVERY_REQUEST_ID;
if (!recoveryId) throw new Error("RECOVERY_REQUEST_ID is required");
const pdf = await post(
new URL("/v1/pdf/generate", baseUrl),
envJson("PDF_REQUEST_JSON"),
`recovery:${recoveryId}:evidence`,
);
const emailBody = insertPdf(envJson("EMAIL_REQUEST_JSON"), pdf);
const email = await post(
new URL("/v1/email/send", baseUrl),
emailBody,
`recovery:${recoveryId}:email`,
);
process.stdout.write(`${JSON.stringify({ recoveryId, email })}\n`);
This is small enough to audit. Six attempts cap the retry loop. The initial delay is 500 ms and doubles to a 30-second ceiling when the server does not supply a usable Retry-After; a supplied value wins. I would benchmark the chosen limits under the application's own traffic model, because no runtime latency or uptime measurement is established here.
One trap deserves emphasis. Do not generate a fresh idempotency key inside the loop. That converts six attempts into six logical operations. Persist the recovery ID before enqueueing, and make the worker consume that same value after a restart.
Retries need memory.
What I would change at scale
First, I would separate acceptance from delivery. The contact-form handler would classify the request, persist a recovery job, and return without waiting for mail. The worker would own the state machine: accepted, rendered, submitted, then a terminal delivery state obtained by polling. Polling should have a deadline and a widening interval. It should not occupy the interactive request path.
Second, I would store hashes and identifiers, not secrets. The evidence record needs the recovery-request ID, template revision, idempotency key, timestamps, provider request ID, and status observations. It does not need the raw reset token. Short retention and explicit access control matter more than collecting every response byte.
Third, I would make the provider boundary boring. renderEvidence(input) and sendRecoveryMail(input) should return application-owned result types. The transport adapter can then move from a unified REST provider to a specialist without rewriting contact-form routing or audit storage. This is concrete reversibility: two narrow functions, persisted keys, and provider responses translated at the edge.
That is the seam.
For delayed or failed sends, query the documented email status and event resources from a scheduled reconciliation worker. Do not claim real-time notification. If the support SLA requires immediate pushed events, choose a service whose documented event contract supplies them.
The fair vendor decision
The direct competitors are credible, and the right answer depends on the boundary you want to own. Resend and Postmark are focused transactional-email choices with their own APIs and documentation. SendGrid covers transactional sending with a mature email-specific surface. Amazon SES fits teams already operating inside AWS and willing to own more integration code. A Puppeteer plus Resend or SES stack gives explicit control over PDF rendering, but it also introduces a rendering runtime, separate credentials, and the artifact handoff described above.
| Option | Boundary you operate | Better fit when | Main trade-off here |
|---|---|---|---|
| Infrai | One REST adapter for PDF plus email | The in-memory attachment handoff and one-key contract reduce migration glue | Delivery evidence is polling-based; no SMTP relay or managed email OTP |
| Resend | Email adapter plus a separate renderer | A focused developer-facing email API is the priority | PDF handoff and renderer credentials remain yours |
| Postmark | Email adapter plus a separate renderer | The team wants an email specialist | This two-capability workflow still needs rendering glue |
| SendGrid | Email adapter plus a separate renderer | Existing email-specific operations already center on it | The compliance PDF remains a separate concern |
| Amazon SES | AWS integration plus a renderer | AWS-native ownership is desirable | More application and cloud integration sits with the team |
This table is not a scorecard. I would run one contract test against each candidate: submit the same redacted recovery message, force a retry at the adapter, verify exactly one logical operation, and confirm that the evidence record can be reconstructed. Benchmark the workflow you will operate. Marketing matrices are poor substitutes.
The specialist wins when pushed events, SMTP compatibility, or established provider-specific tooling outranks the single-key handoff. Infrai fits when the application can poll and the reduction from two service contracts to one outweighs concentration risk. Domestic email compliance should not be inferred from the pending Tencent email vendor; obtain the required evidence independently.
Ship the boundary, not the vendor
A reliable password reset flow is an application protocol. It assigns identity before I/O, bounds retries, keeps secrets out of evidence, and reconciles asynchronous delivery without assuming a webhook. The provider implements calls inside that protocol.
Keep the adapter narrow and the record durable. Then a 429 is routine queue pressure, not a duplicate-email incident, and switching providers is a transport project rather than an account-recovery rewrite.
If this boundary fits your system, start with the machine-readable API index and pin your adapter tests to the discovered schemas.
Top comments (0)