ADR-032: Email Template Engine โ MJML + Handlebars
Date: 2026-06-25 Status: Accepted Issue: #1255 (parent epic #1258) Authors: degen11
Numbering note: #1255 asks for this file at
0NN-email-template-engine.md.024โ031are taken, so this ADR lands at 032.
Contextโ
Every transactional email h.work sends is hand-rolled HTML inlined into
api/src/email/email.service.ts โ 2,476 lines, 22 send*Email methods as of this writing. The information architecture is fully specified in docs/design/TRANSACTIONAL_EMAIL_IA.md: ยง3 catalogs all 22 emails, ยง7 maps each to one of five brand treatments (CLIENT / EXPERT / EXPERT-urgency / AM-Ops / SYSTEM), ยง6.6 lists the structural partials worth preserving.
The hand-rolled approach has three structural problems the IA doc enumerates:
- No shared template / design system. Each method re-inlines the same
<!DOCTYPE>โ table โ header band โ footer skeleton. A palette or copy change is a 22-site edit. - No preview tooling. Designers and PMs cannot see a rendered email without triggering a live send.
- Latent security gaps (ยง6.1). Display fields and invite URLs are escaped ad-hoc per method; one missed
htmlEscapeis an HTML/phishing-injection hole.
This ADR picks the engine that replaces the inlined strings. It does not migrate all 22 templates โ that is the staged work tracked by #1255; this ADR + the foundation slice (engine + preview server + representative templates behind a flag) is PR 1.
Dependency status at time of writing: #1255 declares depends-on: #1252, #1254. #1254 (copy rewrite) merged to dev via #3151 on 2026-06-24 โ the final approved copy now lives in email.service.ts, so migrated templates carry approved copy, not provisional. #1252 (brand tokens / design-system primitives) is still open and unlanded, so the token values in brand.ts are an explicit placeholder for #1252 to fill โ this PR ships the token interface and the engine that consumes it, deliberately not pre-empting that ticket's brand decisions.
Decisionโ
Use MJML + Handlebars, rendered server-side inside the existing NestJS EmailService path.
- MJML compiles a semantic component markup into email-safe HTML (nested
<table>layout, inline CSS, Outlook conditional comments) โ the exact output category that took 22 hand-tuned methods to approximate. It is local-first (a pure compile step, no SaaS). - Handlebars supplies the data layer and partials/components. It auto-escapes
{{ }}interpolations by default, which makes ยง6.1 escaping the default rather than a per-method opt-in; asafeUrlhelper enforces thehttps://-only rule for invite/CTA links. html-to-textauto-derives a plain-text fallback from rendered HTML, satisfying the ยง6.3 plain-text-parity requirement even where a hand-authoredtext.txtis absent.
How it maps to the constraints in #1255โ
| Constraint | How MJML + Handlebars satisfies it |
|---|---|
| Email-safe HTML (table layout, inline CSS) | MJML's entire purpose โ mjml2html emits inlined, Outlook-safe tables |
| Auto-derive plain-text | html-to-text over the rendered HTML; hand-authored text.txt overrides for control |
| Partials / components | Handlebars partials ({{> specialist-card }}) emit MJML fragments; one source per ยง6.6 partial |
| Local-first, no external SaaS | Pure in-process compile; no network at render time |
| Five brand treatments (ยง7) | One BRAND_TREATMENTS token map; the shared header/footer partials read {{brand.*}} |
Alternatives consideredโ
React Email โ runner-upโ
React Email has the best preview DX (a built-in email dev server with hot reload) and auto plain-text. Rejected because it pulls React + a JSX runtime into the api/ package, which has no React today (it is a NestJS backend; React lives only in frontend/). Adding a UI framework as a backend dependency purely for string rendering is the wrong dependency boundary, complicates the build (JSX/TSX transform in the API tsconfig), and ties email rendering to React major-version churn. The preview-server requirement is cheaply met without it (see below).
mjml-reactโ
Authoring MJML semantics in JSX. Same React-toolchain cost as React Email with a smaller, less-maintained ecosystem and no compelling upside over plain MJML + Handlebars for a backend that renders, rather than composes, emails. Rejected.
Status quo (hand-rolled strings)โ
Rejected โ it is exactly what #1255 exists to remove. No design system, no preview, escaping-by-vigilance.
Consequencesโ
Structureโ
api/src/email/
template-engine.service.ts # compile MJML+Handlebars โ { subject, html, text }, cache, helpers
templates/
brand.ts # the five BRAND_TREATMENTS token maps (ยง7)
registry.ts # name โ { audience, treatment, subject(), sample() }
partials/ # shared MJML+Handlebars fragments (ยง6.6)
header.mjml.hbs
footer.mjml.hbs
<template-name>/
template.mjml.hbs # MJML body, Handlebars data + {{> partials}}
text.txt.hbs # plain-text part (Handlebars); html-to-text fallback if absent
subject.ts # default export (data) => string
sample.ts # sample data for preview + tests
preview/
server.ts # `npm run email:preview` โ lists templates, renders HTML/text/subject
EmailService becomes a thin dispatcher: pick template โ supply data โ hand { subject, html, text } to the existing per-audience senderFor() + Resend transport (unchanged by this ADR). Target: email.service.ts < 200 lines with no HTML literals, enforced by test/email-no-html.spec.ts.
Preview serverโ
npm run email:preview starts a zero-dependency Node HTTP server that lists every registered template and, per template, renders the live HTML, the derived plain-text, the computed subject, and the sample-data JSON. Hot reload is free: templates are re-read from disk on every request, so editing a .mjml.hbs and refreshing shows the change โ no watcher needed. CI screenshotting (one PNG per template via Playwright, already provisioned in this environment) is a follow-up within #1255.
Rollout โ feature flagโ
Migration ships behind EMAIL_TEMPLATE_ENGINE=v2. When set, migrated send*Email methods render via the engine; otherwise the legacy inlined path runs unchanged. This lets the v2 path bake in staging while production stays on the proven path, then flips per-environment. Dead legacy methods are removed in a follow-up PR once no regressions are observed (per #1255 "Backward compat during cutover").
New dependenciesโ
mjml, handlebars, html-to-text (all MIT, local-first, no telemetry). No new runtime services.
Buildโ
.mjml.hbs / .txt.hbs template assets are copied to dist/ via a nest-cli.json assets glob so the compiled service resolves them at runtime; the engine resolves the templates directory relative to __dirname (works in both src/ ts-node and dist/ runtime).
Migration statusโ
| Slice | Templates | State |
|---|---|---|
| Foundation (engine + preview + flag) | invite (CLIENT), otp (SYSTEM) | โ merged (#3268) |
| CLIENT / AM-Ops / SYSTEM wired set | welcome, onboarding-invite, onboarding-call-reminder (CLIENT); am-completion (AM-Ops); admin-password-reset (SYSTEM) | โ merged |
| Expert lifecycle (IA ยง4.8โ4.18) + KYC ops alert | expert-waitlist, expert-qualified, expert-interview-invite, expert-interview-reminder (urgency), expert-decline, expert-assignment, expert-onboarding-kickoff, expert-onboarding-reminder-24h, expert-onboarding-reminder-72h (urgency), expert-onboarding-completion, kyc-failed (AM-Ops) | โ this PR |
| Billing CLIENT (IA ยง4.6โ4.7) | trial-ending, payment-failed | โณ deferred โ billing-comms call (ยง6.4 wire-or-delete), no owner; out of scope for this epic until decided |
The slices land five structural partials (ยง6.6): partials/info-panel.mjml.hbs (portal-URL / key-value inset), partials/specialist-card.mjml.hbs (welcome signature element), and partials/progress-bar.mjml.hbs (onboarding progress). The shared header gained an optional {{eyebrow}} override (AM-Ops "h.work Ops", ยง6.8) and the footer an optional {{wordmark}} override (KYC "h.work Ops"). The ยง6.9 unsubscribe split is honoured in the dispatcher: nurture mail (waitlist, both interview/onboarding reminders) keeps List-Unsubscribe + the suppression gate and threads unsubscribeUrl into the footer; decision/action mail (qualified, interview-invite, decline, kickoff, assignment, completion) is strictly transactional. The migrated methods route through the engine only under EMAIL_TEMPLATE_ENGINE=v2; the legacy inlined path is byte-for-byte untouched when the flag is unset.
The ยง6.4 sendExpertOnboardingKickoffEmail (#1083) duplicate no longer exists in the codebase โ already removed in an earlier cleanup, so nothing to delete. The unwired ยง6.4 Expert methods (reminders, completion, KYC) are migrated but still send nothing until their #1554 triggers are wired โ a separate concern from rendering.
Follow-ups (tracked under #1255, not this ADR)โ
- Decide the billing pair (
trial-ending/payment-failed) โ a billing-comms call (ยง6.4), then migrate or formally drop. Copy is sourced from the merged #1254 rewrite already inemail.service.ts; thedocs/design/EMAIL_VOICE_GUIDE.md(#3151) governs it. - Wire the unwired ยง6.4 Expert methods to their #1554 triggers (scheduler / KYC path), or formally defer.
- CI PNG-screenshot artifact per template (headless Chromium is provisioned in the env; the PRs attach per-template screenshots manually).
- Pin template-data contracts with rendering tests (#1256).
- Final cutover: flip
EMAIL_TEMPLATE_ENGINE=v2per-environment, then delete the legacy inlined methods soemail.service.tsdrops under 200 lines with no HTML literals.