The best template approach for password-reset email in Node 22
Compile reset-email HTML in your repo, store one template per locale on Infrai, and treat preview as a test — with the renderer's real limits shown.
Compile the HTML where your design work already happens — React Email, MJML, or a plain Handlebars file — then store the compiled output as one Infrai template per locale and send it by template_id. Design review stays in the repo. The reset copy stops riding on your deploy pipeline, so a translator can fix a German sentence without waiting for a release.
That split is the whole recommendation. The rest of this page is the part that bites: what Infrai’s stored renderer will and won’t do with your markup, how localization behaves once you’re past two languages, and why preview belongs in your test suite instead of your eyeballs.
Where the template actually lives
Three options, and they mostly differ in who owns the diff.
| Approach | Where the HTML lives | Localization cost | Preview fidelity | Copy fix needs a redeploy |
|---|---|---|---|---|
| In-code components (React Email) | Your repo, rendered per send | One component with t() lookups | Local dev server, real components | Yes |
| Compile, then store | Repo for source, Infrai for compiled output | One stored template per locale | Server-rendered against the stored copy | No |
| Vendor WYSIWYG editor | The provider’s dashboard | Hand-managed per locale, easy to drift | The vendor’s editor | No |
React Email’s docs make the first option look painless, and for a single-language product it nearly is. The trouble starts at locale three, when the reset email exists in five variants, your marketing site has its own translation workflow, and a copy change to the German subject line has to go through a pull request, a CI run and a deploy. The second row moves that boundary: the source of truth for design stays in git, but the artefact your users receive is a stored object you can update with one PATCH.
Store the compiled output, not the components.
Infrai’s renderer is flat substitution — plan around it
This is the limitation to design around, and it’s not in any marketing page. POST /v1/email/template/create accepts an HTML body with {{var}} placeholders and substitutes them one for one. Mustache-style section tags are not evaluated. We created a template containing {{#is_admin}}…{{/is_admin}} and previewed it with is_admin set to false:
curl -sS -X POST https://api.infrai.cc/v1/email/template/create \
-H "Authorization: Bearer ${INFRAI_API_KEY:?set your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{
"name": "reset-de-2026",
"subject": "{{app_name}}-Passwort zuruecksetzen",
"html": "<p>Hallo {{first_name}},</p>{{#is_admin}}<p>Adminhinweis</p>{{/is_admin}}<p>Link ({{ttl_minutes}} Minuten): <a href=\"{{reset_url}}\">Neues Passwort</a></p>",
"body_text": "Hallo {{first_name}}, Link ({{ttl_minutes}} Minuten): {{reset_url}}",
"variables": ["first_name", "app_name", "reset_url", "ttl_minutes", "is_admin"]
}'
The stored copy came back with a template_id, and previewing it produced this:
{
"ok": true,
"data": {
"rendered_subject": "Ledgerly-Passwort zuruecksetzen",
"rendered_html": "<p>Hallo Dana,</p>{{#is_admin}}<p>Adminhinweis</p>{{/is_admin}}<p>Link (15 Minuten): <a href=\"https://app.example.net/r?t=x\">Neues Passwort</a></p>",
"missing_vars": [],
"rendered_text": "Hallo Dana, Link (15 Minuten): https://app.example.net/r?t=x"
}
}
The section tag came back verbatim, in the body a user would receive, and missing_vars stayed empty because the renderer never treated it as a variable at all. So conditionals have to be resolved before the HTML is stored — at compile time, or by splitting into separate templates. If you need branching inside the stored copy, that’s a hard no, and an in-repo renderer like React Email is the better pick.
One stored template per locale
Given flat substitution, the cheapest localization scheme is boring: one template per locale, named with a suffix, and a lookup table in your app. Here’s the English reset template as it exists on our account today.
curl -sS -X POST https://api.infrai.cc/v1/email/template/create \
-H "Authorization: Bearer ${INFRAI_API_KEY:?set your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{
"name": "reset-en-2026",
"subject": "Reset your {{app_name}} password",
"html": "<p>Hi {{first_name}},</p><p>Use this link within {{ttl_minutes}} minutes: <a href=\"{{reset_url}}\">Reset password</a></p><p>If you did not ask for this, ignore the message.</p>",
"body_text": "Hi {{first_name}}, reset your {{app_name}} password within {{ttl_minutes}} minutes: {{reset_url}}",
"variables": ["first_name", "app_name", "reset_url", "ttl_minutes"]
}'
Two details worth copying. body_text is what produces rendered_text later — leave it out and the plain-text alternative is simply absent, which costs you with the stricter inbox providers. And the variables array is a declaration, not an enforcement: it does not make the renderer look for those names in the HTML.
Preview is a test, not a screenshot
POST /v1/email/template/preview/{id} is free and takes the variables you’d send at runtime. Run it against a real stored id:
curl -sS -X POST \
https://api.infrai.cc/v1/email/template/preview/tmpl_H1D492c0dIRlBay2Bjt19aLh \
-H "Authorization: Bearer ${INFRAI_API_KEY:?set your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{"vars": {"first_name": "Dana", "app_name": "Ledgerly"}}'
Supplying only two of the four variables returns "missing_vars": ["reset_url", "ttl_minutes"] alongside a rendered subject that looks perfectly fine. That asymmetry is the point: the subject renders, the body still contains a raw {{reset_url}}, and nothing about the HTTP status tells you the email is broken.
There’s a second trap. missing_vars is derived from the placeholders in the stored copy, so a variable you declared and supplied — but which no longer appears in the HTML after a designer’s rewrite — is dropped silently. Preview comes back clean; the reset link never renders. In practice the only defence is asserting on the rendered output, not on missing_vars alone.
// preview-guard.mjs — Node 22, no dependencies
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY=your_infrai_api_key");
const TEMPLATES = { en: "tmpl_H1D492c0dIRlBay2Bjt19aLh", de: "tmpl_xjWTNfteqyiQDjNC3qQ5XwJY" };
async function call(path, init = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json", ...(init.headers ?? {}) },
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
throw new Error(`${path} -> ${res.status} ${payload?.error?.code ?? "unknown"}`);
}
return payload.data;
}
export async function assertResetTemplate(locale, vars) {
const id = TEMPLATES[locale] ?? TEMPLATES.en;
const out = await call(`/v1/email/template/preview/${id}`, {
method: "POST",
body: JSON.stringify({ vars }),
});
if (out.missing_vars.length) throw new Error(`missing: ${out.missing_vars.join(",")}`);
if (!out.rendered_html.includes(vars.reset_url)) throw new Error("reset link absent from rendered body");
if (/\{\{|\{%|\{#/.test(out.rendered_html)) throw new Error("unsubstituted markup survived rendering");
return out;
}
const preview = await assertResetTemplate("en", {
first_name: "Dana",
app_name: "Ledgerly",
reset_url: "https://app.example.net/reset?t=abc",
ttl_minutes: "15",
});
console.log(preview.rendered_subject);
Wire that into CI and a broken locale fails the build instead of a user’s password reset.
Sending it, and what it costs
Once preview passes, the send carries template_id and template_vars instead of subject and html:
curl -sS -X POST https://api.infrai.cc/v1/email/send \
-H "Authorization: Bearer ${INFRAI_API_KEY:?set your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{
"to": "dana@example.net",
"template_id": "tmpl_H1D492c0dIRlBay2Bjt19aLh",
"template_vars": {"first_name": "Dana", "app_name": "Ledgerly", "reset_url": "https://app.example.net/reset?t=abc", "ttl_minutes": "15"}
}'
Omitting from is legal — the send goes out on Infrai’s shared send.infrai.cc sender and the response reports the address it actually used, which is handy on day one before any DNS exists. Adding and verifying your own sender domain is a Pro feature; on a standard key POST /v1/email/domain/verify answers 402 PRO_REQUIRED.
Sends are billed at $0.000115 per email, verified 2026-07-26. Template create, preview, update and the delivery-event reads are free, rate-limited calls. Read today’s number yourself rather than trusting this paragraph:
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY:?set your_infrai_api_key}" \
| python3 -c "import sys,json;[print(c['id'], (c.get('billing') or {}).get('price_usd')) for c in json.load(sys.stdin)['capabilities'] if c['namespace']=='email']"
Rates on this surface move downwards over time and discount campaigns run, so what you find is quite likely lower than what’s printed here. The $2 of trial credit a new account starts with covers roughly 17,391 emails at that rate — more reset mail than most products send in a year.
When to stick with a specialist
If email is the only thing you’re buying, Resend paired with React Email gives you a tighter component story, and Postmark’s transactional deliverability reporting is more detailed than anything shown above. Both are good answers to a narrow question.
The argument for Infrai is a different one. The same key that renders and sends this template also reaches the queue you drop the reset job into, the error tracker that catches the failed render, and the AI models you use elsewhere in the product — one credential, one invoice, one usage query per tenant. Password reset is rarely the only thing an application needs, and the second capability doesn’t cost you a new account.