Missing template variables in a reset email: what preview can't catch

missing_vars comes from the stored copy, not your declared variables — so a deleted sentence previews clean and mails a reset with no link. The two-way test that catches it.

There’s no error code for a malformed template variable, and that’s the trap. On Infrai an unsupplied placeholder isn’t rejected — POST /v1/email/template/preview/{id} returns HTTP 200, lists the name under missing_vars, and renders the literal {{reset_url}} into the HTML. Send it and a real person receives a password-reset email with {{reset_url}} where the button should be. The failure is silent by design, so the fix is a test you own rather than an error you catch.

Worse, missing_vars measures something narrower than most people assume. We checked it on 2026-07-26: it’s derived from the placeholders present in the stored copy, not from the variables list you declared when you created the template. Everything below follows from that one fact.

What missing_vars actually reports

Store a reset template that declares three variables and uses all three:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/email/template/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "reset-link-en",
    "subject": "Reset your {{app_name}} password",
    "html": "<p>Hi {{first_name}},</p><p>Use this link within 30 minutes: <a href=\"{{reset_url}}\">Reset password</a></p>",
    "variables": ["first_name", "app_name", "reset_url"]
  }'

Now render it with one value deliberately withheld:

curl -sS -X POST "https://api.infrai.cc/v1/email/template/preview/tmpl_C36TECaBUZUDrpQQkFoYf1OZ" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"vars": {"first_name": "Dana", "app_name": "Kettle"}}'
{
  "ok": true,
  "data": {
    "rendered_subject": "Reset your Kettle password",
    "rendered_html": "<p>Hi Dana,</p><p>Use this link within 30 minutes: <a href=\"{{reset_url}}\">Reset password</a></p>",
    "missing_vars": ["reset_url"]
  }
}

That’s the well-behaved case. The placeholder survives into the markup as a literal, missing_vars names it, and a send-path guard can refuse to mail it.

The failure a clean preview hides

Copy changes. Someone hands the German version to a translator, the translator restructures a paragraph, and the sentence carrying the link doesn’t survive the round trip. The declared variables list is untouched — it still says reset_url — and your code still supplies it. Here’s what preview says about the translated record:

curl -sS -X POST "https://api.infrai.cc/v1/email/template/preview/tmpl_abXTXnaLga9CETecvlTg40q7" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"vars": {"first_name": "Dana", "app_name": "Kettle", "reset_url": "https://kettle.example/r/6f1c9d2a"}}'
{
  "ok": true,
  "data": {
    "rendered_subject": "Kettle-Passwort zurücksetzen",
    "rendered_html": "<p>Hallo Dana,</p><p>Sie haben ein neues Passwort angefordert.</p><p>Fragen? {{support_url}}</p>",
    "missing_vars": ["support_url"]
  }
}

Read that carefully. reset_url was declared, supplied and silently discarded, because it appears nowhere in the stored HTML — no warning, no error, nothing in missing_vars. Meanwhile support_url, which the translator invented and nobody declared, is flagged. The declared list and the reported list are computed from different sources and neither one is your contract.

An assertion on missing_vars.length === 0 therefore passes this template. And the email it sends has no reset link at all.

Four ways a variable goes wrong

Failuremissing_vars saysThe recipient seesCaught by
Declared, supplied, but deleted from the copynothingAn email with no linkSentinel assertion on rendered_html
In the copy, never suppliedthe nameLiteral {{reset_url}}missing_vars check before send
Typo in the copy ({{reset_ur}})the typo’d nameLiteral bracesmissing_vars check before send
In the copy, undeclared (a translator’s addition)the nameLiteral bracesmissing_vars check before send

Only the first row needs the extra test. It’s also the only one that reaches a customer looking like a normal email.

A two-way contract test

Render with sentinel values, then assert both directions: nothing missing, and every required variable’s sentinel visibly present in the output. Run it in CI for every template and locale you ship.

// template-contract.test.mjs — Node 22 ESM. Run: node --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;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

// Every locale of the reset mail, and the variables the send path guarantees.
const TEMPLATES = {
  en: "tmpl_C36TECaBUZUDrpQQkFoYf1OZ",
  de: "tmpl_abXTXnaLga9CETecvlTg40q7",
};
const REQUIRED = ["first_name", "app_name", "reset_url"];

const sentinel = (name) => `__SENTINEL_${name.toUpperCase()}__`;

async function preview(templateId, vars) {
  const res = await fetch(`${API}/v1/email/template/preview/${templateId}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({ vars }),
    signal: AbortSignal.timeout(10_000),
  });
  const payload = await res.json().catch(() => ({}));
  if (!res.ok || payload.ok === false) {
    const e = payload.error ?? {};
    throw new Error(`preview ${templateId} -> ${e.code ?? res.status}: ${e.message ?? "failed"}`);
  }
  return payload.data;
}

for (const [locale, templateId] of Object.entries(TEMPLATES)) {
  test(`reset template renders completely in ${locale}`, async () => {
    const vars = Object.fromEntries(REQUIRED.map((name) => [name, sentinel(name)]));
    const out = await preview(templateId, vars);

    // Direction 1: the copy asks for nothing we didn't supply.
    assert.deepEqual(out.missing_vars, [], `${locale}: unsupplied placeholders ${out.missing_vars}`);

    // Direction 2: everything we supplied actually reached the output.
    const surface = `${out.rendered_subject}\n${out.rendered_html}`;
    for (const name of REQUIRED) {
      assert.ok(surface.includes(sentinel(name)), `${locale}: ${name} never appears in the rendered copy`);
    }

    // No stray braces left anywhere.
    assert.ok(!/\{\{[^}]+\}\}/.test(surface), `${locale}: unrendered placeholder left in output`);
  });
}

Run that against the German template above and it fails on direction two — reset_url never appears — which is exactly the bug a missing_vars check waves through. Roughly 70 ms per template in our testing, and previews are free, so there’s no reason to sample rather than check every locale on every build.

Guard the send path too

CI covers the copy you shipped; a guard covers the values a request supplies. Both are cheap, and only one of them runs in production:

// send-reset.mjs — Node 22 ESM. Preview, verify, then send.
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");

async function call(path, body) {
  const res = await fetch(API + path, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(10_000),
  });
  const payload = await res.json().catch(() => ({}));
  if (!res.ok || payload.ok === false) {
    const e = payload.error ?? {};
    throw new Error(`${path} -> ${e.code ?? res.status}: ${e.message ?? "request failed"}`);
  }
  return payload.data;
}

export async function sendReset({ to, templateId, vars, linkVar = "reset_url" }) {
  const rendered = await call(`/v1/email/template/preview/${templateId}`, { vars });
  if (rendered.missing_vars.length) {
    throw new Error(`template ${templateId} is missing ${rendered.missing_vars.join(", ")}`);
  }
  if (!rendered.rendered_html.includes(vars[linkVar])) {
    throw new Error(`template ${templateId} rendered without the ${linkVar} value — copy drift`);
  }
  const sent = await call("/v1/email/send", { to, template_id: templateId, template_vars: vars });
  return sent.message_id;
}

const messageId = await sendReset({
  to: "dana@example.com",
  templateId: "tmpl_C36TECaBUZUDrpQQkFoYf1OZ",
  vars: {
    first_name: "Dana",
    app_name: "Kettle",
    reset_url: "https://kettle.example/r/6f1c9d2a",
  },
});
console.log("accepted as", messageId);

The second check is the one that matters and it’s three lines: does the rendered HTML contain the URL you passed in? If not, don’t send — mail nobody a reset they can’t complete.

Cost of checking versus cost of not

Template create, update and preview are free and rate-limited; only the send is metered, at $0.000115 per email, verified 2026-07-26, with $2 of free credit on a new account. Confirm today’s rate:

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([(c['id'], c['billing']['is_billable'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'].startswith('email.template') or c['id'] == 'email.send'])"

curl -sS "https://api.infrai.cc/v1/email/list?limit=5" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Rates trend down over time, so the live figure is likely at or below this one. The asymmetry is what matters: a free preview versus a support queue full of people who can’t log in.

When to render somewhere else

The stored-template engine here is flat substitution — no conditionals, no loops, and section tags come back verbatim. That’s a real limitation, and if your reset mail branches on account type or lists devices, render it with Handlebars or react-email in your own process and post finished markup as html. Postmark’s template API goes further than Infrai’s in exactly this area, with a validation endpoint that returns a suggested model and per-field errors, and SendGrid’s versioned dynamic templates carry test data alongside the copy. Both are the better tool if templating is your problem.

If it isn’t — if the reset mail is one of a dozen things your backend does — the argument for keeping it here is that the CI job running that contract test, the queue behind the send, the error you file when a locale fails and the usage line item it all lands on are the same account and the same key.

References

Browse more email developer guides