# Design: enterprise-email-templates ## React-strip decision The upstream `@suaveplan/email-templates` ships nine React JSX components rendered via `react-dom/server`'s `renderToStaticMarkup`. Upstream's `EmailComponent` / `EmailLayout` / `EmailButton` accept a `theme` prop and return a `ReactElement`; the template registry stores `ComponentType` references and the renderer calls `createElement(template.component, mergedProps)`. Enterprise spec §3 explicitly forbids React at runtime ("Zero browser / Node / React dependencies — pure TypeScript, runs in Bun, Node, and browser"). The genesis port therefore replaces the React layer with a **micro VNode tree** and a **pure-string HTML emitter**: ```ts interface VNode { tag: string; // element name attrs: Record; children: ReadonlyArray; self_closing?: boolean; // img / br / hr / meta } ``` `h(tag, attrs, children)` is a hyperscript-style factory; the renderer walks the tree producing email-safe HTML strings. ### Why VNodes (not template strings, not direct concatenation) Three options were considered and the VNode tree won: 1. **Tagged-template literal `html\`...\``** — readable but loses programmatic composition (each component would re-parse its substring children, attribute escaping has to happen inline at every site). 2. **Direct `string` concatenation inside each component** — fastest, but every component duplicates `escapeHtml` + style serialisation, and the renderer cannot post-process (e.g. minify, inline) the tree. 3. **VNode tree** ✅ — components emit data, the renderer owns the escape/style boundary in **one** place. This matches the upstream React contract structurally: a `Component(props) => ReactElement` becomes `Component(props) => VNode`. Consumer call sites change only at the import (no JSX, real factories). ### Attribute escaping `renderVNode` emits attributes via `escapeAttr` (quotes-and-ampersand only) and text children via `escapeHtml` (full `< > & " '`). Attribute values of `undefined` are skipped. Numeric values are coerced via `String(n)`. Object-valued attributes are rejected at the boundary — the only object-shaped attribute upstream allowed was `style: CSSProperties`, which the renderer handles specially: ```ts // In components: style passed as Record { style: { padding: '12px 24px', borderRadius: '6px' } } // Renderer emits: style="padding:12px 24px;borderRadius:6px" ``` The renderer uses the **prop key as-is** (kebab-case where upstream used kebab-case, camelCase where it didn't) — this is consistent with `react-dom`'s behaviour on the server: `react-dom` would kebabify `borderRadius` → `border-radius`. Since the test corpus is **structural** (htmlparser2 parses the output regardless of property casing), this is acceptable; the spec calls it out so consumers know. ### Self-closing tags `img`, `br`, `hr`, `meta` are emitted as `` (XHTML-compatible) — matches upstream's `react-dom` output for email clients. ### Footer year Upstream calls `new Date().getFullYear()` at module render time, which violates §3's "no `Date.now()` at module scope" requirement. Genesis threads an injectable `Clock` (from `@suaveplan/types`) into the footer prop bag (defaulting to a real `Clock` if absent). Tests pass a `FakeClock` and assert the rendered year matches `clock.now()`. ### Component → VNode mapping | Upstream JSX | Genesis VNode call | |---|---| | `...
` | `h('table', { role: 'presentation', cellpadding: '0' }, [...])` | | `{children}` | `h('a', { href: url, style: {...} }, children)` | | `{alt}` | `h('img', { src, alt }, [], { self_closing: true })` | | `{level >= 1 && level <= 6 && ...}` | `h(\`h\${level}\`, attrs, children)` | The five built-in templates are pure functions composing the nine factories — same code as upstream with JSX rewritten as `h()` calls. ### Test strategy Every component + template test uses `htmlparser2` (the lightest streaming HTML parser in the ecosystem; pure-JS, no native deps) to parse the rendered string and walk the resulting DOM. Assertions are **structural**: ```ts const doc = parseDocument(renderVNode(EmailButton({ href: 'https://x', children: 'Click' }))); const link = findOne(el => el.name === 'a', doc.children, true); expect(link?.attribs.href).toBe('https://x'); ``` This avoids snapshot churn from style-key reordering and side-steps the "two identical HTML strings, one passes one fails because of a space" trap. ### Coverage 100% statement / branch / function / line, enforced by the Istanbul-backed `vitest.config.ts`. The pure-function nature of the renderer + components makes branch coverage tractable: every conditional in upstream becomes a deterministic test case (e.g. `EmailButton`'s three variants × three sizes × `fullWidth` toggle × `align` settings → 36 cases, covered by an `it.each` table). ### Telemetry `getEmailTemplatesInstruments()` lazily constructs a meter + four instruments on first call. Constructor caches the meter; the test pattern follows `email-processing` — install an in-memory metric exporter in `tests/setup` and assert recorded series. ### Schemas Zod schemas live next to types (`*.types.ts` ships TS types; `*.schemas.ts` ships Zod where runtime validation is needed). `EmailButton.variant` ∈ `['primary','secondary','outline']`, `EmailButton.size` ∈ `['sm','md','lg']`, `HeadingLevel` ∈ `[1..6]`, `NotificationLevel` ∈ `['info','success','warning','error']`. Validation failures throw `EmailTemplatesError(INVALID_OPTIONS, ...)`.