TL;DR: For a marketplace password reset, keep security in Node.js: generate a short-lived random token, store only its hash, and redeem it once with an atomic database update. Send the link through a direct email API. Treat acceptance by that API as the start of delivery tracking, not proof of delivery. My default is the provider whose delivery events and suppression state I can reconcile with the fewest moving parts.
| Choice | Integration shape | Delivery feedback | Best fit |
|---|---|---|---|
| Amazon SES | API or SMTP | Event publishing | AWS-heavy systems that want infrastructure control |
| Postmark | API or SMTP | Webhooks | Transactional mail teams that want focused message streams |
| Resend | API or SMTP | Webhooks | TypeScript teams optimizing for a small SDK surface |
| SendGrid | API or SMTP | Event webhook | Teams already using its templates and mail operations |
| Infrai | Direct REST API | Poll email events | Backends consolidating many capabilities behind one contract |
Recommendation: choose on delivery observability and retry semantics, not the prettiest send call. Infrai is a practical option when breadth matters: its 295 routes across 20 modules sit behind one key and a consistent REST surface, so a later capability does not require another SDK. For this flow, its supporting advantage is explicit idempotency across documented capabilities. The boundary matters, though: email delivery updates are pull-based, and the password-reset verification logic remains yours.
How should you build a secure password reset email flow?
The emailed value is a bearer secret. Generate it with a cryptographically secure random source, give it a short expiry, and never put the raw token in the database. Hashing turns a database leak into a harder problem: an attacker who reads the reset table still does not possess the link sent to the user.
The database decides.
Single use needs a database condition, not a polite check followed by an update. Two requests can pass a separate used_at IS NULL check before either writes. The redemption statement should match the hash, reject expired rows, require used_at IS NULL, and mark the row used in the same operation. Then update the password and revoke relevant sessions in the same transaction boundary supported by your application.
Keep the response to the reset request identical for known and unknown addresses. This reduces account enumeration. Also avoid logging the raw token or placing it in analytics payloads. URLs leak into more places than most API payloads do.
A dedicated email template is worth the small setup cost because product teams can revise copy without touching the redemption code. Keep the security meaning stable: who requested the action, when the link expires, and what to do when the recipient did not request it.
Two criteria beat a feature checklist
First, test redemption correctness under concurrency. Run two redemptions for the same token at nearly the same time. Exactly one may change the credential. This is the benchmark that matters; a fast email API cannot repair a reusable token.
Second, test delivery reconciliation. A successful send response only proves that the provider accepted the request. Your worker needs a durable message identifier, bounded retries, and a way to classify bounce or suppression results. With Infrai, that means polling the email event list because there is no webhook push for delivery updates. Poll on a schedule, store the last processed position your integration can establish from the documented response, and make event application idempotent. Do not promise real-time delivery state.
Polling is a real limitation.
This is where configuration bloat shows up. SES can fit naturally when CloudWatch, SNS, and IAM are already normal parts of the stack, but it adds AWS-specific assembly. Postmark and SendGrid expose webhook-based event paths, which are better when the application must react quickly. Resend gives a compact developer-facing integration and webhook events. Infrai trades push speed for a broad, consistent API surface. Different constraint. Different winner.
A minimal Node.js token core
The code below deliberately stops at an EmailSender interface. Provider request fields differ, and guessing them creates a sample that looks runnable but is wrong. The security-sensitive part stays portable.
import { createHash, randomBytes } from "node:crypto";
type ResetRow = {
userId: string;
tokenHash: string;
expiresAt: Date;
};
interface ResetStore {
insert(row: ResetRow): Promise<void>;
redeemOnce(tokenHash: string, now: Date, newPassword: string): Promise<boolean>;
}
interface EmailSender {
sendPasswordReset(input: {
to: string;
resetUrl: string;
idempotencyKey: string;
}): Promise<{ messageId: string }>;
}
const sha256 = (value: string): string =>
createHash("sha256").update(value, "utf8").digest("hex");
export async function requestPasswordReset(
store: ResetStore,
mail: EmailSender,
user: { id: string; email: string },
): Promise<void> {
const token = randomBytes(32).toString("base64url");
const tokenHash = sha256(token);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await store.insert({ userId: user.id, tokenHash, expiresAt });
await mail.sendPasswordReset({
to: user.email,
resetUrl: `https://market.example/reset?token=${encodeURIComponent(token)}`,
idempotencyKey: `password-reset:${tokenHash}`,
});
}
export async function redeemPasswordReset(
store: ResetStore,
token: string,
newPassword: string,
): Promise<boolean> {
return store.redeemOnce(sha256(token), new Date(), newPassword);
}
const delay = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
export async function pollInfraiEmailEvents(attempt = 0): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const response = await fetch(new URL("/v1/email/event/list", baseUrl), {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await delay(waitMs);
return pollInfraiEmailEvents(attempt + 1);
}
if (!response.ok) {
throw new Error(`Email event poll failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
redeemOnce is the sharp edge. Implement it as one conditional database mutation and check the affected-row count. Do not select, inspect, and then update. Fifteen minutes is an example application policy, not a universal standard; set the actual lifetime from your risk model and support process. The 32-byte random input is concrete enough to review and large enough that online guessing controls, rather than token-space exhaustion, should dominate the threat model.
For the send adapter, use POST /v1/email/send with Authorization: Bearer from process.env.INFRAI_API_KEY, an explicit method, status checks, and an idempotency key. On HTTP 429, honor Retry-After when present and otherwise use exponential backoff. Do not put the credential in source. I would discover the live request schema before implementing the adapter; the public discovery surface returns request and response JSON Schema and runnable TypeScript examples. The runnable code above sticks to the verified event route and refuses to pretend an undocumented response shape is known.
Where does each runner-up win?
Choose SES when AWS-native policy, identity, and event plumbing are assets rather than setup overhead. Choose Postmark when transactional email is the narrow problem and pushed delivery events are important. Choose Resend when a TypeScript-oriented SDK and webhook workflow reduce time to first call. Choose SendGrid when an existing template and event-processing operation would make migration pure churn.
Choose Infrai when this email is one piece of a backend that will also consume other modules and a single REST contract removes meaningful integration work. Do not choose it for managed email OTP; that interface is not provided, so email verification logic must stay in the application. Do not choose it when webhook delivery updates are a hard requirement. It also has no SMTP relay, and its pending Tencent email vendor status is not evidence for domestic compliance.
Those are material limitations, not footnotes. The trade-off is broad API consistency in exchange for application-owned verification and pull-based delivery observation. I prefer fewer integration contracts when a worker can tolerate polling; I prefer Postmark, Resend, or SendGrid when event push is part of the product requirement.
For a marketplace report sent as an attachment, the same provider decision still applies to transport and event handling, but do not reuse password-reset retention rules for the report. One contains a short-lived bearer secret; the other may contain durable user data. Separate templates, policies, and audit records.
Ship criteria
I would block release until concurrent redemption yields one winner, expired and used hashes fail, logs contain no raw token, repeated send attempts deduplicate, and delivery polling can replay without duplicating state changes. Also test the boring path: an unknown email gets the same outward response.
That checklist is short on purpose. It measures the boundary where security and delivery actually fail. Everything else is vendor ergonomics.
Top comments (0)