# OpenSpec Change: enterprise-email-templates - **Change ID**: enterprise-email-templates - **Package**: `@suaveplan/email-templates` - **Category**: communication / emails - **Target path**: `packages/communication/emails/email-templates` - **Status**: IN PROGRESS - **Date**: 2026-05-25 - **Reference spec**: `/mnt/s/web/suaveplan/enterprise/email-templates.md` --- ## Summary Deliver `@suaveplan/email-templates` on genesis as a universal-tier, **React-free** email template engine. Upstream ships JSX components rendered via `react-dom/server`; the genesis rebuild honours enterprise spec §3 ("Zero browser / Node / React dependencies — pure TypeScript, runs in Bun, Node, and browser") by re-implementing the full upstream surface as **VNode factories** (`{ tag, attrs, children }`) walked by a pure-string HTML renderer. The full public surface is preserved: nine reusable components (`EmailLayout`, `EmailContainer`, `EmailHeading`, `EmailText`, `EmailLink`, `EmailImage`, `EmailDivider`, `EmailButton`, `EmailFooter`), five built-in templates (`welcome`, `verification`, `password-reset`, `notification`, `invoice`), a typed `TemplateRegistry`, fluent `TemplateBuilder`, theming primitives (`defaultTheme` / `createTheme` / `mergeThemes`), the `createEmailMessage` adapter, and renderer helpers (`renderToHtml`, `wrapInDocument`, `resolveSubject`, `escapeHtml`, `stripHtml`). Genesis adds structured `EmailTemplatesError` codes, Zod-validated public schemas, injectable `Clock` (to remove `new Date().getFullYear()` at module scope inside the footer), and OTel instruments on every render. --- ## Current-state audit (genesis) The package does not exist on genesis yet. Upstream lives at `/mnt/s/web/suaveplan/packages/universal/email-templates/` (73 files, 4,949 LOC, React 19 runtime dep, 100% coverage). Every consumer presently imports JSX-returning `ComponentType`s; the genesis port must change the **component shape** from `ComponentType

` to `(props: P) => VNode` while keeping the same exported names. --- ## Available workspace deps on genesis | Package | Role | |---|---| | `@suaveplan/error` | `EmailTemplatesError`; `EMAIL_TEMPLATES.*` code registry via `defineCodes` | | `@suaveplan/types` | `Clock`, injectable for the footer copyright year | | `@suaveplan/opentelemetry` | Lazy meter accessor (sibling-package convention) | | `@opentelemetry/api` (`catalog:observability`) | Counters + histogram | | `zod` (`catalog:forms`) | Public-input validation | | `tslib` (`catalog:build`) | Runtime helpers | Dev deps: `@suaveplan/testing` (runner wrapper), `@vitest/coverage-istanbul`, `vitest`, `vite`, `vite-plugin-dts`. --- ## Public API surface (React-stripped) ### VNode core ```ts export interface VNode { readonly tag: string; // 'table' | 'tr' | 'td' | 'a' | ... readonly attrs: Readonly>; readonly children: ReadonlyArray; readonly self_closing?: boolean; // img, br, hr, meta } export type EmailNode = VNode | string; export type EmailChildren = EmailNode | ReadonlyArray; export function h( tag: string, attrs?: Record, children?: EmailChildren, ): VNode; ``` ### Components (factories returning `VNode`) ```ts EmailLayout(props: EmailLayoutProps): VNode EmailContainer(props: EmailContainerProps): VNode EmailHeading(props: EmailHeadingProps): VNode EmailText(props: EmailTextProps): VNode EmailLink(props: EmailLinkProps): VNode EmailImage(props: EmailImageProps): VNode EmailDivider(props: EmailDividerProps): VNode EmailButton(props: EmailButtonProps): VNode EmailFooter(props: EmailFooterProps): VNode ``` Each prop shape is identical to upstream **except** `children` is now `EmailChildren` instead of `ReactNode`, and `style` is `Record` instead of `CSSProperties`. ### Templates ```ts WelcomeEmail(props: WelcomeProps & { theme?: EmailTheme; clock?: Clock }): VNode VerificationEmail(props: VerificationProps & { theme?: EmailTheme; clock?: Clock }): VNode PasswordResetEmail(props: PasswordResetProps & { theme?: EmailTheme; clock?: Clock }): VNode NotificationEmail(props: NotificationProps & { theme?: EmailTheme; clock?: Clock }): VNode InvoiceEmail(props: InvoiceProps & { theme?: EmailTheme; clock?: Clock }): VNode export const welcomeTemplate: EmailTemplate; export const verificationTemplate: EmailTemplate; export const passwordResetTemplate:EmailTemplate; export const notificationTemplate: EmailTemplate; export const invoiceTemplate: EmailTemplate; ``` ### Renderer / registry / builder / theme / message ```ts export interface EmailTemplate { readonly name: string; readonly subject: string | ((props: TProps) => string); readonly component: (props: TProps & { theme?: EmailTheme; clock?: Clock }) => VNode; readonly defaultProps?: Partial; } export function renderToHtml(template: EmailTemplate, props: T, options?: RenderOptions): RenderResult; export function wrapInDocument(body: string, options?: HtmlDocumentOptions): string; export function resolveSubject(template: EmailTemplate, props: T): string; export function escapeHtml(str: string): string; export function stripHtml(html: string): string; export function renderVNode(node: EmailNode): string; // new — VNode → HTML export class TemplateRegistry { /* register/get/has/list/search/remove/clear */ } export function createRegistry(opts?: RegistryOptions): TemplateRegistry; export class TemplateBuilder { /* name / subject / component / defaultProps / build */ } export function createTemplateBuilder(): TemplateBuilder; export const defaultTheme: EmailTheme; export function createTheme(opts?: EmailThemeOptions): EmailTheme; export function mergeThemes(base: EmailTheme, override: EmailThemeOptions): EmailTheme; export function createEmailMessage( template: EmailTemplate, props: T, recipient: EmailRecipient, options?: CreateEmailMessageOptions, ): EmailMessageResult; ``` ### Error codes ```ts EMAIL_TEMPLATES.INVALID_TEMPLATE EMAIL_TEMPLATES.INVALID_OPTIONS EMAIL_TEMPLATES.RENDER_FAILURE EMAIL_TEMPLATES.DUPLICATE_NAME EMAIL_TEMPLATES.UNKNOWN_TEMPLATE EMAIL_TEMPLATES.INVALID_HEADING_LEVEL ``` ### OTel instruments - `email_templates.render_total{template,outcome}` — counter - `email_templates.render_duration_ms{template}` — histogram - `email_templates.registry_register_total{outcome}` — counter - `email_templates.validation_failure_total{reason}` — counter --- ## Genesis additions 1. **VNode renderer** — new `renderVNode` function walks the tree producing HTML with: HTML-encoded text (`&<>"'`), inline `style="..."` emission from object styles, MSO-conditional comments preserved from upstream `wrapInDocument`, self-closing for `img`/`br`/`hr`/`meta`. 2. **Injectable `Clock`** — footer copyright year reads from `clock.now()` instead of `new Date().getFullYear()` at call time; default supplied if omitted. 3. **Structured errors** — `EmailTemplatesError` extends `SuaveplanError` with six stable codes. 4. **Zod schemas** — public input validation for `EmailButtonProps.variant/size`, heading level (1–6), notification level, footer link, registry options. 5. **OTel instrumentation** — counters + histogram wired in `renderToHtml`, `TemplateRegistry.register`, and the validation pathway. 6. **Structured-HTML test assertions** — tests use `htmlparser2` (added to a sensible catalog) to parse rendered output and assert tag structure / attributes — never raw string snapshots, never mocks. --- ## File layout ```text packages/communication/emails/email-templates/ package.json tsconfig.lib.json vite.config.ts vitest.config.ts README.md src/ index.ts error-codes/{error-codes.ts, error-codes.test.ts, error-codes.md, index.ts} telemetry/{telemetry.ts, telemetry.test.ts, telemetry.md, index.ts} vnode/{vnode.ts, vnode.test.ts, vnode.md, vnode.types.ts, index.ts} theme/{theme.ts, theme.test.ts, theme.md, theme.types.ts, index.ts} renderer/{renderer.ts, renderer.test.ts, renderer.md, renderer.types.ts, index.ts} message/{message.ts, message.test.ts, message.md, message.types.ts, index.ts} template-builder/{template-builder.ts, template-builder.test.ts, template-builder.md, template-builder.types.ts, index.ts} template-registry/{template-registry.ts, template-registry.test.ts, template-registry.md, template-registry.types.ts, index.ts} components/ email-layout/{email-layout.ts, email-layout.test.ts, email-layout.md, email-layout.types.ts, index.ts} email-container/{...} email-heading/{...} email-text/{...} email-link/{...} email-image/{...} email-divider/{...} email-button/{...} email-footer/{...} templates/ welcome/{welcome.ts, welcome.test.ts, welcome.md, welcome.types.ts, index.ts} verification/{...} password-reset/{...} notification/{...} invoice/{...} ``` --- ## Affected dependents None on genesis yet — `emailer`/`campaigns` consume their own minimal templating today; they may opt in to this package post-archive. ## Workspace dep prerequisites `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`. All ✅ on `genesis`.