Skip to main content

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โ€“031 are 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:

  1. 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.
  2. No preview tooling. Designers and PMs cannot see a rendered email without triggering a live send.
  3. Latent security gaps (ยง6.1). Display fields and invite URLs are escaped ad-hoc per method; one missed htmlEscape is 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; a safeUrl helper enforces the https://-only rule for invite/CTA links.
  • html-to-text auto-derives a plain-text fallback from rendered HTML, satisfying the ยง6.3 plain-text-parity requirement even where a hand-authored text.txt is absent.

How it maps to the constraints in #1255โ€‹

ConstraintHow MJML + Handlebars satisfies it
Email-safe HTML (table layout, inline CSS)MJML's entire purpose โ€” mjml2html emits inlined, Outlook-safe tables
Auto-derive plain-texthtml-to-text over the rendered HTML; hand-authored text.txt overrides for control
Partials / componentsHandlebars partials ({{> specialist-card }}) emit MJML fragments; one source per ยง6.6 partial
Local-first, no external SaaSPure 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โ€‹

SliceTemplatesState
Foundation (engine + preview + flag)invite (CLIENT), otp (SYSTEM)โœ… merged (#3268)
CLIENT / AM-Ops / SYSTEM wired setwelcome, onboarding-invite, onboarding-call-reminder (CLIENT); am-completion (AM-Ops); admin-password-reset (SYSTEM)โœ… merged
Expert lifecycle (IA ยง4.8โ€“4.18) + KYC ops alertexpert-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 in email.service.ts; the docs/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=v2 per-environment, then delete the legacy inlined methods so email.service.ts drops under 200 lines with no HTML literals.