9.4 KiB
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 ComponentTypes; 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
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)
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
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
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
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}— counteremail_templates.render_duration_ms{template}— histogramemail_templates.registry_register_total{outcome}— counteremail_templates.validation_failure_total{reason}— counter
Genesis additions
- VNode renderer — new
renderVNodefunction walks the tree producing HTML with: HTML-encoded text (&<>"'), inlinestyle="..."emission from object styles, MSO-conditional comments preserved from upstreamwrapInDocument, self-closing forimg/br/hr/meta. - Injectable
Clock— footer copyright year reads fromclock.now()instead ofnew Date().getFullYear()at call time; default supplied if omitted. - Structured errors —
EmailTemplatesErrorextendsSuaveplanErrorwith six stable codes. - Zod schemas — public input validation for
EmailButtonProps.variant/size, heading level (1–6), notification level, footer link, registry options. - OTel instrumentation — counters + histogram wired in
renderToHtml,TemplateRegistry.register, and the validation pathway. - 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
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.