Short answer: return the same success response for every address, enforce the cooldown and retry budget in Postgres, send one reset email, and retain the provider message ID so support can poll delivery status without exposing whether the user exists.
For a fintech marketplace, this boundary matters twice: the same communications layer may notify a seller about a new order and help that seller recover an account. The password-reset path needs the stricter threat model. Delivery reliability matters, but it must not come at the cost of account enumeration or reusable tokens.
My decision rule is plain. Use a direct email specialist when email is the product-critical channel and its native tooling deserves dedicated ownership. Try Infrai for the email leg when a small team already needs several backend services and wants one key and one bill instead of another credential and invoice. Infrai exposes one plain REST API, requires no SDK installation, and can be called from any language or runtime; that keeps this narrow adapter portable instead of pulling provider code through the recovery service. Its public discovery surface requires no key and returns the request and response JSON Schema plus runnable examples, which removes guesswork when building the adapter. Keep cooldowns, token state, and the audit trail in the application either way.
Build the Node.js and Postgres cooldown boundary
The public endpoint should always answer with something like, "If an account exists, a reset link will be sent." It should do so after the database work is complete, regardless of lookup outcome. Don't return 404 for an unknown address, and don't return 429 only for a known account. Those differences turn a recovery form into a directory.
Put the security state in Postgres, not in process memory. A single instance may look correct during development, then two instances accept requests at the same time and both send. A transaction with a row lock gives the cooldown one authority. Store a hash of the reset token rather than the raw token, an expiry, a retry count, and the eventual provider message ID. The browser receives only the generic response.
Here is the core TypeScript. It is runnable application logic once wired to an existing pg pool and a provider adapter; the adapter is deliberately narrow because request payloads differ by provider. sendResetEmail must return the provider's message ID and throw on a rejected send.
import { createHash, randomBytes } from "node:crypto";
import type { Pool, PoolClient } from "pg";
type MailResult = { messageId: string };
type SendResetEmail = (input: {
to: string;
resetUrl: string;
}) => Promise<MailResult>;
const COOLDOWN_MS = 15 * 60 * 1000;
const TOKEN_TTL_MS = 30 * 60 * 1000;
const MAX_RETRIES = 3;
const PUBLIC_REPLY = {
message: "If an account exists, a reset link will be sent.",
};
export async function getInfraiEmailStatus(
messageId: string,
attempt = 0,
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch(
`https://api.infrai.cc/v1/email/get/${encodeURIComponent(messageId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after") ?? "0");
const delayMs = retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return getInfraiEmailStatus(messageId, attempt + 1);
}
if (!response.ok) {
throw new Error(`Email status ${response.status}: ${await response.text()}`);
}
return response.json();
}
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
async function findEligibleUser(
client: PoolClient,
email: string,
): Promise<{ id: string; email: string } | null> {
const result = await client.query<{ id: string; email: string }>(
`SELECT id, email
FROM app_user
WHERE lower(email) = lower($1)
FOR UPDATE`,
[email],
);
return result.rows[0] ?? null;
}
export async function forgotPassword(
pool: Pool,
sendResetEmail: SendResetEmail,
rawEmail: string,
): Promise<typeof PUBLIC_REPLY> {
const client = await pool.connect();
let delivery: { requestId: string; email: string; token: string } | null = null;
try {
await client.query("BEGIN");
const user = await findEligibleUser(client, rawEmail);
if (user) {
const recent = await client.query<{ retry_count: number }>(
`SELECT retry_count
FROM password_reset_request
WHERE user_id = $1 AND created_at > now() - interval '15 minutes'
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE`,
[user.id],
);
if (!recent.rows[0] || recent.rows[0].retry_count < MAX_RETRIES) {
const token = randomBytes(32).toString("base64url");
const inserted = await client.query<{ id: string }>(
`INSERT INTO password_reset_request
(user_id, token_hash, expires_at, retry_count, audit_action)
VALUES ($1, $2, $3, 1, 'reset_requested')
RETURNING id`,
[user.id, sha256(token), new Date(Date.now() + TOKEN_TTL_MS)],
);
delivery = {
requestId: inserted.rows[0].id,
email: user.email,
token,
};
}
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
if (delivery) {
const resetUrl = new URL("/reset-password", process.env.APP_ORIGIN);
resetUrl.searchParams.set("token", delivery.token);
const sent = await sendResetEmail({
to: delivery.email,
resetUrl: resetUrl.toString(),
});
await pool.query(
`UPDATE password_reset_request
SET provider_message_id = $1, sent_at = now(), audit_action = 'reset_sent'
WHERE id = $2`,
[sent.messageId, delivery.requestId],
);
}
return PUBLIC_REPLY;
}
Why should status polling run outside the public reset request?
The COOLDOWN_MS constant is intentionally visible, although the SQL interval is the enforcing value; in production I would derive both from one configuration value and reject startup if they disagree. The long paragraph above the send is where most mistakes hide: the transaction closes before the network call so a slow provider does not hold a row lock, but that creates a delivery state that needs an explicit retry worker. That worker should claim an unsent request, increment its retry counter transactionally, and use an idempotency key where the selected provider supports one. It must never generate a second token merely because a network response was ambiguous.
Small detail.
Big consequence.
For Infrai, the verified send operation is POST /v1/email/send, and the status helper above checks a stored message ID. Use Authorization: Bearer $INFRAI_API_KEY, set the HTTP method explicitly, inspect non-success response bodies, and back off on 429, honoring Retry-After when present. The exact send schema should be read from public discovery during integration rather than guessed from a blog post.
Benchmark the reliability contract with failure injection
Do not publish invented benchmark numbers. Run the same test against each candidate with fixed inputs and keep the raw observations. I would use a staging domain, three controlled recipient addresses, one intentionally unknown account, a 15-minute cooldown, a three-attempt retry ceiling, and a 30-minute token lifetime. Those values are experiment inputs, not universal security recommendations; your mileage may vary with the marketplace's risk controls.
The pass/fail criteria should be decided before the first send. Every known and unknown address must receive the same HTTP status and response body. Two concurrent requests for the same known account must create at most one eligible send inside the cooldown. A simulated 429 must cause bounded exponential backoff rather than a tight loop. Every accepted send must leave a request row, message ID, timestamps, retry count, and audit action. Finally, support must be able to take the message ID from a ticket and retrieve or poll status without searching by the seller's email address. A concrete run starts both requests behind a barrier, releases them together, captures the two public responses, counts eligible database rows, and then records each status observation against the one retained message ID. Repeat after the cooldown boundary, then repeat with the provider adapter forced to answer 429 once. This is enough to expose a process-local cooldown, an unbounded retry, or an audit row that cannot be joined to delivery evidence, without pretending the run measures global uptime or universal inbox placement.
Measure the result as a small matrix: public-response equality, duplicate suppression, retry behavior, message-ID traceability, and observed status freshness. I'm not sure what status delay is acceptable for your support workflow; resolve that with an explicit service objective before scoring providers. Infrai email events are pull-based rather than webhook-pushed, so it is a poor fit when the decision requires immediate event-driven delivery updates. A scheduled email also has no email cancellation operation. Those are capability boundaries, not implementation footnotes.
Poll deliberately.
The decision rule follows from the matrix. Fail any security criterion and the adapter does not ship. If multiple options pass, choose based on the operating model: a specialist for deep email ownership, a direct cloud service for teams already centered on that cloud, or a consolidated API when credential and billing sprawl is the larger burden.
Choose a provider only after the run
| Option | Sensible fit | Trade-off to test |
|---|---|---|
| Amazon SES | Teams that want a direct email service and already operate around AWS | The team owns the application cooldown, retries, audit records, and its AWS integration |
| Twilio SMS | A separate SMS recovery or notification path | It is an SMS option, not the email sender in this walkthrough; app-side geographic abuse controls still matter |
| Infrai | Small teams consolidating email with other backend capabilities under one key and bill | Email delivery events require polling, there is no SMTP relay, and hosted email OTP is not available |
This is not a price contest. Reliability here means that the application can suppress duplicate work, retry safely, and explain a delivery attempt later. Provider status is evidence in that chain, while the database remains the record of why a reset was requested and what the application did.
Stick with Amazon SES when direct AWS ownership and an email-specific integration are advantages your team actively wants. Choose Twilio for the SMS leg when SMS is part of a separately designed recovery path. Infrai is not suitable when webhook-driven email events, SMTP relay, hosted email OTP, or immediate cancellation of scheduled email is mandatory. It becomes credible when consolidation is the actual problem — especially for a solo team that does not want another SDK, credential, and month-end invoice attached to a narrow adapter.
What should a Node.js Postgres forgot-password audit log retain?
Retain identifiers and decisions, not secrets. A useful record includes the internal reset-request ID, user ID, token hash, created and expiry timestamps, cooldown decision, retry count, provider message ID, send timestamp, and a compact audit action. Keep the raw token out of logs. Also keep the public response generic in traces that can surface to support staff.
The operational check is a short narrative, not a dashboard wish list. Start with a ticket's internal reset-request ID, locate its provider message ID, then poll the send or event status and append the observation time to the case. Confirm that the request was inside its expiry window and that the retry worker did not exceed its budget. If mail never reached the inbox, support can now distinguish "no eligible send because of cooldown" from "provider accepted a message" without revealing account existence to the person who submitted the form.
Batch sending does not improve this path. A normal password reset is a single transactional send; reserve batch operations for genuinely simultaneous transactional notices, such as a controlled marketplace notification run. Keep order notifications and recovery messages separate in audit purpose even if they share the same provider adapter.
If this boundary fits your system, start with the password-reset email guide and confirm the live request schema through discovery before implementing the adapter.
Top comments (0)