A password reset email should be owned like authentication code, even when the same customer-support service also sends generated case reports as attachments. Keep the security copy, HTML, plain text, expiry language, and preview checks in a versioned template that the application team can review. Let the delivery provider transport it. This preserves brand control without making a vendor dashboard the only source of truth.
TL;DR: render both parts in Node.js, use one unambiguous reset action, make the expiry explicit, and preview the exact template before production sends. Authenticate a domain and align the visible sender with it. Send recovery mail immediately; a delayed reset job creates a cancellation problem on platforms that cannot cancel scheduled email.
That ownership decision matters more than a fashionable component library. In a support workflow, an agent may trigger recovery while viewing a case, and the backend may also produce a report for the customer. Those messages have different risk profiles. A report can tolerate explanatory copy. A reset message should be short, transactional, and difficult to mistake for marketing.
Keep it boring.
Before and after: move the template boundary
The fragile model sounds convenient: edit HTML in a provider console, paste different text into staging, and let each environment accumulate its own version. The application knows that it requested a reset, but it cannot prove what the customer saw. A brand edit can also change the accessible name of the call to action without passing code review.
The better mental model is a small pipeline. Application data enters a versioned renderer; HTML and text leave together; a preview gate inspects them; the delivery API sends the approved result. In words: reset request, tokenized HTTPS link, deterministic render, automated assertions, human preview, immediate send, then pull delivery events for operational review.
Pulling events is an important boundary. Infrai provides one key and one bill across backend services plus one REST API over plain HTTP, with no SDK to install, but its email events are pull-only rather than webhook events. A team using it must budget for polling in its observability design. The public, self-describing discovery surface requires no key, describes 295 routes across 20 modules, and provides runnable examples in 10 languages. One credential reduces key sprawl; ordinary fetch also keeps template ownership separate from a provider library and its release cycle. Neither advantage removes the need to own the template or the polling loop.
A copyable Node.js renderer with preview assertions
This example deliberately stops at the provider boundary. It needs no SDK, makes no assumptions about undocumented request fields, and can run in CI. The output object is what an adapter hands to the selected email API.
The sending adapter below reads a request that has already been checked against the live discovery schema. That is less cute than inventing a to or templateId field for an article, and much more useful: schemas change, while the transport obligations do not. Put the approved rendered message into EMAIL_REQUEST_JSON, use a stable operation ID from the reset request, and let a startup validation step reject a payload that does not match the discovered schema. The adapter makes one immediate write, retries only rate limits, and reports the provider's actual error body.
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const requestJson = process.env.EMAIL_REQUEST_JSON;
const operationId = process.env.RESET_OPERATION_ID;
if (!apiKey || !baseUrl || !requestJson || !operationId) {
throw new Error("INFRAI_BASE_URL, INFRAI_API_KEY, EMAIL_REQUEST_JSON, and RESET_OPERATION_ID are required");
}
const requestBody: unknown = JSON.parse(requestJson);
async function sendResetEmail(attempt = 0): Promise<unknown> {
const response = await fetch(`${baseUrl}/email/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": operationId,
},
body: JSON.stringify(requestBody),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return sendResetEmail(attempt + 1);
}
const body = await response.text();
if (!response.ok) throw new Error(`Email send failed (${response.status}): ${body}`);
return body.length > 0 ? JSON.parse(body) : null;
}
console.log(await sendResetEmail());
One route. One write.
type ResetInput = {
productName: string;
resetUrl: string;
expiresInMinutes: number;
supportEmail: string;
};
type RenderedEmail = {
subject: string;
html: string;
text: string;
};
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export function renderPasswordReset(input: ResetInput): RenderedEmail {
const url = new URL(input.resetUrl);
if (url.protocol !== "https:") throw new Error("resetUrl must use HTTPS");
if (!Number.isInteger(input.expiresInMinutes) || input.expiresInMinutes <= 0) {
throw new Error("expiresInMinutes must be a positive integer");
}
const product = escapeHtml(input.productName);
const safeUrl = escapeHtml(url.toString());
const support = escapeHtml(input.supportEmail);
const expiry = `${input.expiresInMinutes} minutes`;
return {
subject: `Reset your ${input.productName} password`,
text: [
`Reset your ${input.productName} password`,
"",
`Open this secure link: ${url.toString()}`,
`This link expires in ${expiry}.`,
"If you did not request this, you can ignore this email.",
`Need help? Contact ${input.supportEmail}.`,
].join("\n"),
html: `<!doctype html>
<html lang="en">
<head>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<style>
body { margin: 0; background: #f5f7fa; color: #17202a; font-family: Arial, sans-serif; }
main { max-width: 600px; margin: 0 auto; padding: 32px 20px; background: #ffffff; }
a.button { display: inline-block; padding: 14px 20px; color: #ffffff; background: #1457d9; }
@media (prefers-color-scheme: dark) {
body { background: #111418; color: #f4f6f8; }
main { background: #1b2026; }
a.button { color: #ffffff; background: #3f7cff; }
}
</style>
</head>
<body>
<main>
<h1>Reset your ${product} password</h1>
<p>This link expires in ${expiry}.</p>
<p><a class="button" href="${safeUrl}">Reset password</a></p>
<p>If the button does not work, open:<br><a href="${safeUrl}">${safeUrl}</a></p>
<p>If you did not request this, you can ignore this email.</p>
<p>Need help? Contact <a href="mailto:${support}">${support}</a>.</p>
</main>
</body>
</html>`,
};
}
export function assertPreview(email: RenderedEmail, resetUrl: string): void {
const checks: Array<[boolean, string]> = [
[email.subject.length > 0, "subject is missing"],
[email.html.includes('lang="en"'), "document language is missing"],
[email.html.includes("prefers-color-scheme: dark"), "dark-mode styles are missing"],
[email.html.includes(">Reset password</a>"), "CTA name changed"],
[email.html.includes(resetUrl), "HTML fallback URL is missing"],
[email.text.includes(resetUrl), "plain-text URL is missing"],
[email.text.includes("expires in"), "plain-text expiry is missing"],
];
const failures = checks.filter(([passed]) => !passed).map(([, message]) => message);
if (failures.length > 0) throw new Error(failures.join("; "));
}
const message = renderPasswordReset({
productName: "Northstar Support",
resetUrl: "https://accounts.example.com/reset?token=example-test-token",
expiresInMinutes: 20,
supportEmail: "support@example.com",
});
assertPreview(message, "https://accounts.example.com/reset?token=example-test-token");
console.log(JSON.stringify(message, null, 2));
The 20 is test data, not a universal security recommendation. Your identity policy determines the real lifetime. Keep the value in one input so the rendered promise cannot drift from token validation.
There is another deliberate choice here: the visible URL repeats the same action as the button. Screen-reader users get meaningful link text, while people whose clients strip styles still have a path forward. The plain-text alternative is a first-class output, not HTML with tags removed after the fact.
Dark mode remains client-dependent. The meta declarations and media query express intent; they do not guarantee identical rendering in every mailbox. Preserve contrast in both palettes, avoid putting essential meaning in background images, and inspect real previews. Crisp tests catch omissions. Visual review catches surprises.
Who should own the reusable template?
Provider-hosted templates and application-owned templates are both valid. The deciding question is who must approve a security-message change.
| Option | Ownership model | Useful boundary | Trade-off |
|---|---|---|---|
| Amazon SES | Application teams typically compose content or use SES templates | Teams already operating deeply in AWS | More assembly remains with the application and AWS account controls |
| Postmark | Provider templates with an API and editor | Product and engineering share transactional-template work | Template state can live outside the application repository |
| SendGrid | Dynamic templates managed through its UI and API | Teams that want marketer-friendly editing plus API delivery | Governance must prevent an editor change from bypassing security review |
| Resend | API-oriented email with React Email support | TypeScript teams that want components close to code | Component ownership still requires mailbox preview testing |
These are differences in control surfaces, not a universal ranking. SES is a natural fit when AWS ownership already defines the operational boundary. Postmark and SendGrid make hosted editing attractive when non-developers legitimately own parts of the message. Resend pairs neatly with a code-first TypeScript workflow.
For recovery mail, I would keep the canonical source in the repository and treat any hosted copy as a deployment artifact. The reason is plain: the expiry sentence and destination URL are security behavior. A support report attachment can use a more editorial template, but sharing colors, sender identity, and review tooling prevents the two message families from drifting off-brand.
How should password reset email HTML and text handle dark mode?
Yes. Brand safety is consistency of identity and meaning, not pixel equality. The HTML version can carry the approved type, spacing, and colors. The text version carries the same product name, action, expiry, fallback URL, and help address in a form every client can expose.
This is a real trade-off. Highly styled HTML gives brand teams more control, yet every extra visual dependency creates another mailbox-client failure mode. For a recovery message, I choose the smaller design surface because the customer is trying to regain access, not admire a campaign. The short text part then acts as an independent recovery path rather than a compliance checkbox.
Use a verified sending domain and an aligned sender identity. Google’s sender guidance covers authentication and alignment expectations; those delivery controls belong beside template review, not in a later polish pass. Minimal marketing copy also helps the message stay focused. One CTA is enough.
Do not turn the email into a support transcript. Never put a password, one-time secret, or sensitive case report into the template. A reset link should lead back to the authenticated product flow, while a generated support report should follow its own attachment and data-retention policy.
What about retries, scheduling, and delivery signals?
Send reset emails immediately. If a user asks twice, the identity system should decide which token remains valid; an email scheduler cannot make that policy decision. On a platform where scheduled email has no cancellation route, delaying recovery messages also leaves the application unable to retract the queued send.
Retries need care. A transport retry should carry an idempotency key where the provider supports one, and the application should surface non-success responses rather than assuming delivery. Rate limits require exponential backoff and respect for Retry-After. Those rules belong in the adapter around the renderer, because changing providers should not alter the approved words.
Event handling is separate again. If a provider offers webhooks, verify their signatures and make consumers idempotent. If it offers pull-only events, poll on a measured interval and alert on stale checkpoints. No single delivery signal proves that a human read the message, so dashboards should name the state they actually observe: API accepted, delivered, bounced, or unknown.
That separation is the payoff. Template tests protect meaning. Domain controls protect identity. Delivery telemetry protects operations. None is asked to impersonate the other two.
Ship those three boundaries together.
Further reading
- Google, “Email sender guidelines”: https://support.google.com/a/answer/81126
- NIST SP 800-63B, “Digital Identity Guidelines: Authentication and Lifecycle Management”: https://pages.nist.gov/800-63-3/sp800-63b.html
- Amazon SES, “Using templates to send personalized email”: https://docs.aws.amazon.com/ses/latest/dg/send-personalized-email-api.html
- Postmark, “Templates API”: https://postmarkapp.com/developer/api/templates-api
- SendGrid, “How to Send an Email with Dynamic Templates”: https://www.twilio.com/docs/sendgrid/ui/sending-email/how-to-send-an-email-with-dynamic-templates
- Resend, “Send emails with React”: https://resend.com/docs/send-with-react
- MDN,
prefers-color-scheme: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
Top comments (0)