Files
suaveplan/openspec/archive/2026-05-25-enterprise-email-templates/design.md
2026-05-27 23:29:53 +01:00

82 lines
5.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<TProps>` 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<string, string | number | undefined>;
children: ReadonlyArray<VNode | string>;
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<string, string | number>
{ 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 `<img ... />` (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 |
|---|---|
| `<table role="presentation" cellPadding={0}>...</table>` | `h('table', { role: 'presentation', cellpadding: '0' }, [...])` |
| `<a href={url} style={{...}}>{children}</a>` | `h('a', { href: url, style: {...} }, children)` |
| `<img src={src} alt={alt} />` | `h('img', { src, alt }, [], { self_closing: true })` |
| `{level >= 1 && level <= 6 && <h{level}>...</h{level}>}` | `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, ...)`.