Initial Commit
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user