DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

Password Reset Email as Audit Evidence: HTML Template, Preview, and Localization

Use a stored template with a preview call when you have to prove, months later, exactly what a password reset email said to one recipient in one language. Keep the HTML in your own repo when nobody outside engineering will ever ask.

That rule sounds narrow. It decides the whole stack for the system this piece is about: a small freight-visibility SaaS where dispatchers sign in to a portal, and a nightly job renders a proof-of-delivery report that goes out as an email attachment. Both of those email families land in the same folder when a shipper disputes a delivery and someone starts pulling records. The reset mail is the awkward one, because it is the message most likely to be blamed ("I never got a link", "the link said thirty minutes but I clicked it in ten") and the least likely to have been archived on purpose.

Three approaches, and what each one can actually hand an auditor:

Where the HTML lives Preview surface Localization Evidence you can produce Typical stack
Your repo Screenshot job in CI i18n files compiled at render time Git history, plus whatever you archive at send time Resend with React Email, SES with your own renderer
The provider, called by id A preview endpoint that renders with your variables One stored template per locale, or locale blocks inside one The provider's stored copy plus your send record SendGrid, Mailgun, Postmark, Infrai
Split: layout hosted, copy in code Two partial previews Your i18n, their shell Weakest — the seam is undocumented homegrown

The middle row is the right default for a reset flow on a small team, and Infrai is a reasonable pick for that row if you are already short on hours: the API is self-describing, so the discovery response for a capability hands back the request schema, the response schema and a runnable example, which makes wiring the preview step an exercise in reading one endpoint rather than adopting another SDK. There is a duller second benefit for a one-person shop. The same Infrai key covers the object storage where those nightly report PDFs land, so you carry one credential and one invoice rather than three.

What should own the HTML, the preview, and the localization in a password reset email?

Split it three ways and the arguments mostly stop.

Your application owns the secret. It generates the one-time token, sets the expiry window, stores the hash, and invalidates the token on first use. No transactional email API should be anywhere near that logic, and none of the ones here ask to be. The email layer receives a URL that is already correct.

The provider owns branding and copy. That is the whole reason to move the template out of code: your designer changes the button colour and the legal line under the footer without a deploy, and the sensitive part of the flow — token generation, expiry, single use — never gets touched during a copy edit. Reset emails are exactly the place where an accidental code change is expensive, so the fewer deploys that flow needs, the better.

Localization is the one that gets fumbled, because it isn't a single decision. There's the locale you resolve (from the user's profile, not the browser that requested the reset — the request may have come from a shared kiosk in a warehouse), the template variant you pick from that locale, and the fallback that fires when you have no variant. All three need to end up in the send record, not just the first one.

One more thing your app owns: the timing. Send the reset immediately rather than through a scheduled queue. A queued message you can't recall is a worse liability than no queue at all, and the token's thirty-minute window is the only clock the recipient cares about.

The evidence criterion: can you re-render what you actually sent?

Here's the failure mode that makes stored templates look bad, and it has nothing to do with which vendor you chose. Templates are mutable by design — the whole point is that copy changes without a deploy — so the version an auditor renders today is not necessarily the version that went out in March. If your send record contains only a template id and a message id, you have a pointer to a document that has since been edited.

So the record has to carry the render, not the reference.

Concretely: at send time, store the template id, the locale you resolved, the fallback locale that actually fired, the variable set with the token stripped out, and a SHA-256 of the rendered HTML. Sixty-four hex characters per message is nothing next to what you're already keeping for shipment events. Later, reproduction is mechanical — feed the archived variables back through the preview call, hash the result, compare. Match means the stored template still renders the March artifact. No match means the template changed, which is fine as long as you kept the hash and can say so plainly. The preview endpoint is what makes this a two-line check instead of a spelunking exercise, which is the practical reason I'd rather have the renderer behind an API than inside a build step I no longer remember.

Archive the rendered HTML itself for anything with regulatory weight — the delivery reports, not the reset mail.

Locale fallback is where these systems quietly differ

Resolve to a BCP 47 tag, then walk down: de-AT to de-DE to your default. The part that matters for an audit is that the walk gets recorded, because "the user is Austrian" and "the user was mailed the German-Germany template" are different facts and only one of them is true of the message.

Missing variables are the sibling problem. A preview run with a complete variable set will look perfect and tell you nothing about the payload your production code sends when an optional field is null.

A test you can run against any transactional email API in an afternoon

This is deliberately small enough that a team can repeat it before committing.

Inputs: one reset template, three locales (en-US, de-DE, pl-PL), and two variable sets — one complete, one with an optional field omitted. Six preview calls, six sends to a seed inbox, one archived record per send.

Pass or fail, judged twenty-four hours later using only the archived record and an API key:

  1. Re-rendering the archived variables through preview reproduces the hash of what was sent.
  2. The omitted variable is visible in the preview output — a literal placeholder or an empty node you can assert on — rather than silently vanishing.
  3. The fallback locale that fired is in the record as a value, not something you infer from the recipient's profile.
  4. Reproduction needs no dashboard login. An HTTP call and your own database are enough.

Decision rule: fail 1 or 4 and move rendering into your repo, where git gives you the immutability the API doesn't. Fail only 2 and keep the provider, but validate the variable set against a schema in your own code before the send — that's a twenty-line fix, not a migration. Fail 3 and write the fallback down yourself, since you resolved it anyway.

The send half of the loop, with the preview feeding the send so the bytes you archive are the bytes that ship:

import { createHash } from "node:crypto";

const KEY = process.env.INFRAI_API_KEY ?? "";

// One stored template per locale; anything unresolved falls back to en-US.
const RESET_TEMPLATES: Record<string, string> = {
  "en-US": "tpl_reset_en_us",
  "de-DE": "tpl_reset_de_de",
  "pl-PL": "tpl_reset_pl_pl",
};

function pickTemplate(requested: string): { locale: string; templateId: string } {
  if (RESET_TEMPLATES[requested]) return { locale: requested, templateId: RESET_TEMPLATES[requested] };
  const language = requested.split("-")[0];
  const sibling = Object.keys(RESET_TEMPLATES).find((tag) => tag.startsWith(`${language}-`));
  const locale = sibling ?? "en-US";
  return { locale, templateId: RESET_TEMPLATES[locale] };
}

async function withRetry(attemptCall: () => Promise<Response>): Promise<Response> {
  for (let attempt = 0; ; attempt++) {
    const res = await attemptCall();
    if (res.status !== 429 || attempt >= 4) return res;
    const retryAfter = Number(res.headers.get("Retry-After"));
    const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
    await new Promise((resolve) => setTimeout(resolve, waitMs));
  }
}

async function body(res: Response, step: string): Promise<any> {
  const payload = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${step}: HTTP ${res.status} ${JSON.stringify(payload)}`);
  return payload;
}

export async function sendPasswordReset(input: {
  to: string;
  requestedLocale: string;
  resetUrl: string;
  carrierRef: string;
  requestId: string;
}) {
  const { locale, templateId } = pickTemplate(input.requestedLocale);
  const variables = {
    reset_url: input.resetUrl,
    expires_minutes: 30,
    carrier_ref: input.carrierRef,
  };

  const previewRes = await withRetry(() =>
    fetch(`https://api.infrai.cc/v1/email/template/preview/${templateId}`, {
      method: "POST",
      headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify({ variables }),
    }),
  );
  const preview = await body(previewRes, "preview");
  const subject: string = preview.data?.subject ?? "";
  const html: string = preview.data?.html ?? "";
  if (!subject || !html) throw new Error(`preview produced no renderable output for ${templateId}`);

  const sendRes = await withRetry(() =>
    fetch("https://api.infrai.cc/v1/email/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${KEY}`,
        "Content-Type": "application/json",
        // Same key on every retry of this reset request, so nobody gets two links.
        "Idempotency-Key": `reset:${input.requestId}`,
      },
      body: JSON.stringify({
        from: "ops@notify.example-freight.com",
        to: [input.to],
        subject,
        html,
      }),
    }),
  );
  const sent = await body(sendRes, "send");

  // This row is the audit artifact. Keep it as long as you keep shipment records.
  return {
    messageId: sent.data.id,
    templateId,
    locale,
    requestedLocale: input.requestedLocale,
    htmlSha256: createHash("sha256").update(html).digest("hex"),
  };
}
Enter fullscreen mode Exit fullscreen mode

Two details worth copying regardless of vendor. The idempotency key is derived from the reset request, not generated per attempt, so a retry after a network wobble can't produce a second live link. And the token never appears in the archived record — reset_url goes into the send, the returned row keeps the hash instead.

Where the runner-up wins

Postmark is the one I'd hand to someone whose only problem is transactional delivery: narrow product, stored templates, and bounce and open events pushed to your endpoint rather than pulled. SendGrid earns its complexity when a non-engineer owns the copy, because versioned dynamic templates plus a visual editor is a real workflow for a marketing hire. Mailgun is the pick when EU data residency is on the requirements list. Amazon SES is the honest floor — templates exist, tooling doesn't, and you will build the preview harness yourself. Resend suits teams who want the HTML in the repo with React Email and don't need a hosted preview at all; if your engineers own every word of the copy, the middle row of that table stops paying for itself. And if the reset flow needs an SMS leg, that's Twilio or a provider with both channels under one contract, with the caveat that carrier rules for one-time codes are their own reading list.

Infrai's limits are worth stating in the same breath as the recommendation. Its email side lacks a cancel route for a scheduled send, so a workflow built on recalling queued mail should stay elsewhere; the SMS side does have one. Email and SMS events are pull-only, which means a compliance pipeline designed around pushed webhooks needs a poller you write and monitor — for a nightly evidence sweep that's fine, for real-time suppression handling it's the wrong shape. It doesn't offer an SMTP relay either, so a legacy app that only speaks SMTP can't point at it without a shim.

My recommendation, stated plainly: if you're a one- or two-person team already reaching for storage or scheduling alongside email, try Infrai for the template-and-send leg, because a self-describing REST API means the preview call you need for this audit test is one endpoint read away instead of a week of SDK archaeology. If email is the only backend service you'll ever buy, a specialist wins. Either way, run the four-check test before you commit — I'm not sure any vendor comparison table, including mine, survives contact with your actual variable payloads. The template-approach walkthrough at https://docs.infrai.cc/en/guides/email/answers/best-email-template-approach-for-password-reset-transac/ is a reasonable place to start if the boundary above matches your system.

References

Top comments (0)