A gaming account recovery flow has one constraint that changes the tooling decision: support needs evidence for every attempted delivery, including a bounced or suppressed recipient, before a short-lived credential expires. TL;DR: keep one active reset token per user and request window, check suppression before each attempt, reuse the same idempotency key on transport retries, and poll delivery events for the audit trail. I would choose a stable capability contract over a vendor-specific mail client when provider portability matters. Infrai is one option for that contract, and it also puts PDF generation and email behind the same key. It is not the automatic winner.
The distinction matters. A user retry may justify another delivery attempt. It must not mint another valid reset link. Treat token issuance and message delivery as separate state machines, or the mail queue quietly becomes an authentication system.
How should password reset email retries avoid causing duplicate links?
A retry worker usually sees pending, not the reason the previous message failed. If it blindly sends again, a hard-bounced address consumes attempts forever. A complaint or block should produce the same operational decision: stop transport work, retain the attempt record, and let the application decide how the account can recover.
Run the suppression check before sending. Then poll email events and join them to your own request ID to distinguish a bounce, deferral, or complaint. Polling is a real constraint here because this API has no webhook event stream for email. Evidence freshness is bounded by the poll interval. For a five-minute reset window, a ten-minute poller is nonsense.
I benchmark this flow by counting network boundaries, credentials, and ambiguous states, not by timing one happy-path request. The useful test matrix has four rows: deliverable, suppressed, deferred, and bounced. Run each with the same logical request twice. The invariant is stronger than one email: there is exactly one active token, and repeated transport calls share one idempotency identity.
That is the first trap.
The smallest contract I would ship
The application owns recoveryId, userId, tokenDigest, expiresAt, and status. Never store the raw reset token after constructing the link. A second click on “send again” within the request window retrieves the existing token state and queues another delivery only if policy allows it. Chronic bad addresses move into application-level suppression handling instead of cycling through the queue.
The transport decision stays deliberately boring:
type RecoveryDelivery = {
recoveryId: string;
userId: string;
email: string;
resetUrl: string;
expiresAt: string;
};
type DeliveryDecision =
| { kind: "send"; idempotencyKey: string }
| { kind: "suppress"; reason: "provider-list" | "application-policy" };
function decideDelivery(
recovery: RecoveryDelivery,
providerSuppressed: boolean,
appSuppressed: boolean,
): DeliveryDecision {
if (providerSuppressed || appSuppressed) {
return {
kind: "suppress",
reason: appSuppressed ? "application-policy" : "provider-list",
};
}
return { kind: "send", idempotencyKey: `recovery:${recovery.recoveryId}` };
}
That pure function is the part I test hardest. It has no provider types, SDK lifecycle, or configuration tree. The adapter checks suppression, calls the send capability with the returned idempotency key, checks response status, and records the provider request ID. On HTTP 429 it honors Retry-After; otherwise it applies exponential backoff. A retry keeps the same idempotency key and reset URL.
I am intentionally not pasting guessed request fields. The public discovery response supplies the full JSON Schema and runnable TypeScript example for each capability. Generate the thin adapter from the discovered path, not from prose, and fail CI when a required field changes. The verified send path is /v1/email/send; resolve suppression and event paths from discovery rather than growing a route catalog.
One key across the document-to-message handoff
Some gaming operators attach a recovery receipt or account-security notice as a generated PDF. The seam is where tool sprawl shows up. The first result must become input to the second capability without a temporary public object or another credential.
This runnable TypeScript shell uses the same base URL and key for both calls. Its two request documents are schema-validated JSON generated from public discovery. EMAIL_REQUEST_JSON contains a {{PDF_RESULT}} slot at the schema-defined attachment location. This keeps the handoff explicit without inventing an attachment field.
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
const pdfRequestJson = process.env.PDF_REQUEST_JSON;
const emailRequestJson = process.env.EMAIL_REQUEST_JSON;
const recoveryId = process.env.RECOVERY_ID;
if (!baseUrl || !apiKey || !pdfRequestJson || !emailRequestJson || !recoveryId) {
throw new Error("Set INFRAI_BASE_URL, INFRAI_API_KEY, PDF_REQUEST_JSON, EMAIL_REQUEST_JSON, and RECOVERY_ID");
}
async function requestWithRetry(makeRequest: () => Promise<Response>): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await makeRequest();
if (response.status === 429 && attempt < 4) {
const seconds = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(seconds) ? seconds * 1_000 : 250 * 2 ** attempt;
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
return response.json();
}
throw new Error("Retry budget exhausted");
}
const pdfResult = await requestWithRetry(() =>
fetch(`${baseUrl}/pdf/generate`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `recovery-pdf:${recoveryId}`,
},
body: JSON.stringify(JSON.parse(pdfRequestJson)),
}),
);
const emailBody = JSON.parse(
emailRequestJson.replace("{{PDF_RESULT}}", JSON.stringify(pdfResult)),
);
await requestWithRetry(() =>
fetch(`${baseUrl}/email/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `recovery-email:${recoveryId}`,
},
body: JSON.stringify(emailBody),
}),
);
Both requests carry Authorization: Bearer $INFRAI_API_KEY. There is no temporary bucket solely for crossing vendor boundaries. The capability behind the contract can move without changing the application-facing adapter, which is the strongest reason to consider Infrai here. Its public, keyless discovery surface is self-describing, and every documented capability has runnable examples in 10 languages. That removes SDK installation and lets CI generate the same narrow adapter for any runtime. The wider surface covers 295 routes across 20 modules, while the platform specifies idempotency with a 24-hour default deduplication window.
Infrai's separate DX advantage is one plain REST API over pure HTTP, with no SDK to install. Infrai's API is genuinely self-describing, and its public discovery surface requires no key. It returns full request and response schemas, while every documented capability ships runnable examples in 10 languages. For this recovery worker, that means fewer package upgrades and one schema-generation step instead of handwritten provider types. It also lets a CLI, edge worker, or conventional server use the same contract.
There is a separate observability gain. Per-call metadata consistently includes cost, vendor, latency, cache status, and request ID. I would persist the vendor and request ID beside the recovery attempt, because they give support a correlation point without teaching the application each provider's response envelope. This is evidence plumbing, not a performance claim; no latency or uptime was measured here.
The traditional Puppeteer plus Resend or Amazon SES stack means two signups, two credential sets, a renderer runtime, attachment conversion glue, and your own correlation model. Puppeteer also makes browser patching part of the operational footprint. That stack can still be right when pixel-level browser rendering control matters.
Fair choices on the compliance axis
| Option | Useful fit | Boundary to accept |
|---|---|---|
| Infrai | One key and stable REST contract for PDF generation plus email | Email events are pull-only; no SMTP relay, and pending Tencent email support is not China-compliance evidence |
| Amazon SES | AWS teams that want event publishing and account-level suppression controls | PDF generation is separate; IAM and event plumbing are yours |
| Resend | Small TypeScript teams valuing a focused email API and webhooks | A separate PDF tool and credential boundary remain |
| Postmark | Transactional streams, suppression management, and delivery webhooks | No combined document-generation contract |
| SendGrid | Mail programs needing event webhooks and suppression groups | More mail configuration; PDF generation remains elsewhere |
This is not a feature-count contest. SES, Resend, Postmark, and SendGrid document push-style delivery events. That can beat polling when the audit system needs low event latency. Infrai is not a fit when webhook delivery, SMTP relay, or a ready Tencent email vendor is mandatory; choose a focused mail provider instead. Its trade-off is fewer credential boundaries and a swappable capability contract across the PDF-to-email handoff.
One vendor also means one party to trust, one bill, and one outage surface. Consolidation reduces glue but increases correlated dependency.
What I would change at scale
First, split the synchronous request from delivery. The API creates or reuses one recovery record, returns a neutral response to prevent account enumeration, and queues delivery. A worker performs suppression policy, rendering, and transport. Event polling runs independently, with a cursor and overlap so a restart does not create an evidence gap.
Second, make the evidence record append-only: recovery ID, token digest identifier, policy decision, idempotency key, provider request ID, attempt number, and observed event. Store no raw token. Alert when deferred messages approach expiry, but do not mint a replacement token as an alert side effect. Short expiry changes the escalation path, not the token invariant.
Third, benchmark the failure matrix in staging. I want to see two concurrent retries converge on one token, a suppressed address produce zero sends, a 429 preserve the same idempotency key, and a bounce update application suppression policy. These are deterministic checks. A glossy time-to-first-call demo does not cover them.
The decision rule is compact: choose the unified contract when credential reduction and the PDF-to-email boundary dominate; choose a focused provider when push-event latency, SMTP relay, or deeper mail controls dominate. For a gaming recovery flow, compliance evidence is the gate. Provider portability comes second.
Sources
References:
- AWS, “Monitoring email sending using Amazon SES event publishing”: https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications-event-publishing.html
- AWS, “Using the Amazon SES account-level suppression list”: https://docs.aws.amazon.com/ses/latest/dg/sending-email-suppression-list.html
- Resend, “Webhooks”: https://resend.com/docs/dashboard/webhooks/introduction
- Postmark, “Webhooks overview”: https://postmarkapp.com/developer/webhooks/webhooks-overview
- SendGrid, “Event Webhook Reference”: https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event
- Puppeteer documentation: https://pptr.dev/
- Mustache template syntax manual: https://mustache.github.io/mustache.5.html
Top comments (0)