import Handlebars from 'handlebars'; /** The raw, unrendered strings of a template (from the DB row or an inline source). */ export interface RawTemplate { subject?: string | null; bodyHtml?: string | null; bodyText?: string | null; /** Declared variable names. Every one MUST be supplied at render time (fail loud). */ variables?: string[] | null; } export interface RenderedContent { subject?: string; html?: string; text?: string; } /** A declared variable was not supplied — we refuse to send a half-rendered "Hi ," message. */ export class MissingTemplateVariableError extends Error { constructor(public readonly missing: string[]) { super(`missing required template variable(s): ${missing.join(', ')}`); this.name = 'MissingTemplateVariableError'; } } /** * Pure render: interpolate `vars` into a template's strings. HTML output is auto-escaped (values * like a customer name go into an email body — XSS risk); subject and plaintext are NOT escaped. * No I/O — the caller supplies the raw template. Throws if any declared variable is absent. */ export function renderTemplate(tpl: RawTemplate, vars: Record): RenderedContent { const declared = tpl.variables ?? []; const missing = declared.filter((name) => vars[name] === undefined || vars[name] === null); if (missing.length > 0) throw new MissingTemplateVariableError(missing); const out: RenderedContent = {}; if (tpl.subject != null) out.subject = compile(tpl.subject, false)(vars); if (tpl.bodyHtml != null) out.html = compile(tpl.bodyHtml, true)(vars); if (tpl.bodyText != null) out.text = compile(tpl.bodyText, false)(vars); return out; } /** `escape=true` → HTML-escape interpolated values (for the html body); false → verbatim. */ function compile(src: string, escape: boolean): Handlebars.TemplateDelegate { return Handlebars.compile(src, { noEscape: !escape, strict: false }); }