Files
2026-05-27 23:29:53 +01:00

207 lines
9.4 KiB
Markdown
Raw Permalink 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.
# 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<P>` 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<Record<string, string | number | undefined>>;
readonly children: ReadonlyArray<VNode | string>;
readonly self_closing?: boolean; // img, br, hr, meta
}
export type EmailNode = VNode | string;
export type EmailChildren = EmailNode | ReadonlyArray<EmailNode>;
export function h(
tag: string,
attrs?: Record<string, string | number | undefined>,
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<string, string | number>` 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<WelcomeProps>;
export const verificationTemplate: EmailTemplate<VerificationProps>;
export const passwordResetTemplate:EmailTemplate<PasswordResetProps>;
export const notificationTemplate: EmailTemplate<NotificationProps>;
export const invoiceTemplate: EmailTemplate<InvoiceProps>;
```
### Renderer / registry / builder / theme / message
```ts
export interface EmailTemplate<TProps = unknown> {
readonly name: string;
readonly subject: string | ((props: TProps) => string);
readonly component: (props: TProps & { theme?: EmailTheme; clock?: Clock }) => VNode;
readonly defaultProps?: Partial<TProps>;
}
export function renderToHtml<T>(template: EmailTemplate<T>, props: T, options?: RenderOptions): RenderResult;
export function wrapInDocument(body: string, options?: HtmlDocumentOptions): string;
export function resolveSubject<T>(template: EmailTemplate<T>, 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<TMap extends TemplateMap = TemplateMap> { /* register/get/has/list/search/remove/clear */ }
export function createRegistry<T extends TemplateMap = TemplateMap>(opts?: RegistryOptions): TemplateRegistry<T>;
export class TemplateBuilder<TProps> { /* name / subject / component / defaultProps / build */ }
export function createTemplateBuilder<TProps>(): TemplateBuilder<TProps>;
export const defaultTheme: EmailTheme;
export function createTheme(opts?: EmailThemeOptions): EmailTheme;
export function mergeThemes(base: EmailTheme, override: EmailThemeOptions): EmailTheme;
export function createEmailMessage<T>(
template: EmailTemplate<T>,
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`.