Password reset template: dark mode, plain text and an a11y checklist
Markup that survives real mail clients, a plain-text part that isn't generated for you, and a preview call you can assert on in CI instead of eyeballing screenshots.
Two things decide whether a reset email is readable: the markup degrades sensibly in clients that ignore half your CSS, and there’s a plain-text alternative for the ones that ignore all of it. Infrai’s template routes cover the storage and rendering side — POST /v1/email/template/create holds the markup, POST /v1/email/template/preview/{id} renders it with test variables — and the preview response is machine-readable, which turns “does this look right” into an assertion rather than a screenshot review.
One finding up front, because it changed how we write these: the plain-text part is not generated from your HTML. If you don’t supply body_text, preview comes back with no rendered_text at all, and text-only recipients get nothing useful. We’ll show that failure and its fix below.
What survives a real mail client
| Technique | Reality | Do this instead |
|---|---|---|
| External stylesheet | Stripped almost everywhere | Inline styles on each element |
| Flexbox / CSS grid | Unreliable in Outlook desktop | One-column <table role="presentation"> |
prefers-color-scheme | Honoured by Apple Mail and iOS; ignored or overridden elsewhere | Declare <meta name="color-scheme" content="light dark"> and pick colours that work either way |
| Background images | Blocked by default in many clients | Solid background colour, image as decoration only |
| Web fonts | Ignored by most | System font stack |
<button> | Not clickable in several clients | A padded <a> styled as a button |
| Font under 14px | Auto-zoomed on mobile | 16px body, 20px+ heading |
Assume image blocking is on. If the message doesn’t make sense with every image suppressed, it doesn’t make sense.
The markup
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</head>
<body style="margin:0;padding:24px;background:#f4f4f5;color:#18181b;font:16px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:8px;">
<tr>
<td style="padding:32px;">
<h1 style="margin:0 0 16px;font-size:22px;line-height:1.3;color:#18181b;">Reset your {{product}} password</h1>
<p style="margin:0 0 16px;">Hi {{first_name}},</p>
<p style="margin:0 0 24px;">Someone asked to reset the password for your {{product}} account. If that was you, choose a new one:</p>
<p style="margin:0 0 24px;">
<a href="{{reset_url}}"
style="display:inline-block;padding:12px 20px;background:#1d4ed8;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;">Choose a new password</a>
</p>
<p style="margin:0 0 24px;font-size:14px;color:#3f3f46;">If the button doesn't work, paste this into your browser:<br>
<span style="word-break:break-all;">{{reset_url}}</span></p>
<p style="margin:0;font-size:14px;color:#3f3f46;">The link expires in {{minutes}} minutes. If you didn't ask for it, you can ignore this email — your password won't change.</p>
</td>
</tr>
</table>
</body>
</html>
Four accessibility details are doing real work there. lang="en" tells a screen reader which pronunciation rules to use. role="presentation" stops the layout table being announced as a data table with rows and columns. The link text says what it does rather than “click here”, which matters when a screen reader lists links out of context. And white on #1d4ed8 clears the WCAG 2.2 AA threshold of 4.5:1 for normal text, while #3f3f46 on white clears it for the small print — pick your two brand colours by measuring that ratio, not by taste.
Storing it
import { readFile } from "node:fs/promises";
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const html = await readFile("./templates/password-reset.html", "utf8");
const res = await fetch(`${API}/v1/email/template/create`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: "password-reset-a11y-v1",
subject: "Reset your {{product}} password",
html,
variables: { product: "string", first_name: "string", reset_url: "string", minutes: "string" },
}),
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
console.error("template.create failed:", payload.error ?? res.status);
process.exit(1);
}
console.log("template_id:", payload.data.template_id);
Reading the markup from a file keeps it out of your request handler, and out of the JSON escaping that makes templates unreviewable in a pull request.
The plain-text half is not automatic
Preview the template you just created and look at what’s missing.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/email/template/preview/tmpl_Zxdu96wunNvrpWiE0Eo1pXkm" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"vars":{"product":"Acme","first_name":"Dana","reset_url":"https://app.example.com/reset?t=REDACTED"}}'
{
"ok": true,
"data": {
"rendered_subject": "Reset your Acme password",
"rendered_html": "<!doctype html><html lang=\"en\">…</html>",
"missing_vars": ["minutes"]
}
}
No rendered_text key, and missing_vars caught a variable the caller forgot. Both are useful signals — the second is why preview belongs in CI, and the first is a gap you have to close by hand. The optional body_text field on the template routes is where the plain-text alternative lives; set it and preview starts returning rendered_text.
curl -sS -X PATCH "https://api.infrai.cc/v1/email/template/update/tmpl_Zxdu96wunNvrpWiE0Eo1pXkm" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"body_text":"Reset your {{product}} password\n\nHi {{first_name}},\n\nOpen this link to choose a new password:\n{{reset_url}}\n\nThe link expires in {{minutes}} minutes. If you did not request it, ignore this email."}'
Write the text part as text. Don’t strip tags from the HTML — a machine-flattened version reads like a fax, and the URL, which is the only thing that matters in a reset mail, ends up buried between navigation crumbs.
Preview as a test
import test from "node:test";
import assert from "node:assert/strict";
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const TEMPLATE_ID = process.env.INFRAI_RESET_TEMPLATE_ID ?? "";
async function preview(vars) {
const res = await fetch(`${API}/v1/email/template/preview/${TEMPLATE_ID}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ vars }),
});
const payload = await res.json();
if (!res.ok || payload.ok === false) throw new Error(JSON.stringify(payload.error ?? res.status));
return payload.data;
}
const FIXTURE = {
product: "Acme",
first_name: "Dana",
reset_url: "https://app.example.com/reset?t=TESTTOKEN",
minutes: "15",
};
test("every declared variable is supplied", async () => {
const out = await preview(FIXTURE);
assert.deepEqual(out.missing_vars, []);
});
test("both parts carry the reset link", async () => {
const out = await preview(FIXTURE);
assert.ok(out.rendered_html.includes(FIXTURE.reset_url));
assert.ok(out.rendered_text && out.rendered_text.includes(FIXTURE.reset_url));
});
test("html declares a language and a colour scheme", async () => {
const out = await preview(FIXTURE);
assert.match(out.rendered_html, /<html[^>]+lang="en"/);
assert.match(out.rendered_html, /color-scheme/);
});
Run that in CI and a copy edit that quietly drops the plain-text link fails the build. It’s three free calls per run.
Copy that doesn’t read like a phishing attempt
Keep the subject boring and literal. Name the product in the first sentence, state what was requested, tell the reader what happens if they ignore it, and never ask them to reply with anything. Urgency language — “immediate action required”, countdown timers — is the register attackers use, so borrowing it trains your own users badly.
One sentence about who it’s from beats a logo.
Sending it
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to":"user@example.com","from":"security@auth.example.com","template_id":"tmpl_Zxdu96wunNvrpWiE0Eo1pXkm","template_vars":{"product":"Acme","first_name":"Dana","reset_url":"https://app.example.com/reset?t=REDACTED","minutes":"15"}}'
What this costs
Template creation, updates and previews are free and rate-limited, and previews don’t consume the new-account trial. Only the send is metered: $0.000115 per recipient, verified 2026-07-26, with $2 of starting credit covering roughly 17,391 messages. Rates in this market keep drifting downward as vendor discounts land, so read today’s rather than trusting a paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(next(c['billing'] for c in d['capabilities'] if c['id']=='email.send'))"
Where to get better markup than this
Postmark’s transactional templates are MIT-licensed, client-tested and include a password reset — start there and adapt, rather than writing table layouts from scratch. If your team already writes React, Resend’s react-email components give you JSX authoring with the table markup generated for you, which is a nicer editing experience than a string in a repo.
The limitations worth knowing: preview renders, it doesn’t screenshot, so there’s no client-by-client rendering matrix here and you’d be better off with a service like Litmus if visual QA across 40 clients is a requirement. Templates have no versioning or rollback — PATCH /v1/email/template/update/{id} overwrites in place, so keep the HTML in git and treat the API copy as a deployment target. And a send referencing a template that no longer exists fails at request time rather than degrading, which is EMAIL_INVALID_STATE territory.