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

9.4 KiB
Raw Blame History

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} — 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

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.