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

16 KiB

Capability Spec: email-templates

Package: @suaveplan/email-templates Status: ACTIVE

Every requirement carries a **Verified by:** line at authoring time.

ADDED Requirements

Requirement: Error code registry

The package SHALL declare EMAIL_TEMPLATES_CODES via defineCodes("EMAIL_TEMPLATES", [...]) covering INVALID_TEMPLATE, INVALID_OPTIONS, RENDER_FAILURE, DUPLICATE_NAME, UNKNOWN_TEMPLATE, INVALID_HEADING_LEVEL. A EmailTemplatesError class SHALL extend SuaveplanError<EmailTemplatesCode> and tag itself with name === "EmailTemplatesError".

Scenario: every code is namespaced

  • WHEN EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE is read
  • THEN the value equals "EMAIL_TEMPLATES.INVALID_TEMPLATE" and every other declared code resolves to its dotted form.

Scenario: error preserves cause

  • WHEN new EmailTemplatesError(EMAIL_TEMPLATES_CODES.RENDER_FAILURE, "key", { cause: new Error("boom") }) is constructed
  • THEN err.name === "EmailTemplatesError", err.code === "EMAIL_TEMPLATES.RENDER_FAILURE", (err.cause as Error).message === "boom".

Verified by: src/error-codes/error-codes.test.ts::"exposes every code with namespaced value" AND src/error-codes/error-codes.test.ts::"EmailTemplatesError preserves cause".


Requirement: VNode core and HTML renderer

The package SHALL expose a VNode interface { tag, attrs, children, self_closing? }, a hyperscript factory h(tag, attrs?, children?), and a renderVNode(node) function that returns a string. Text children MUST be HTML-escaped (& < > " '). Attribute values of undefined MUST be skipped. Object-valued style attributes MUST be serialised to inline CSS key:value;.... Self-closing tags (img, br, hr, meta) MUST emit <tag ... />.

Scenario: text escape

  • WHEN renderVNode(h("p", {}, "1 < 2 & x")) is called
  • THEN the output equals <p>1 &lt; 2 &amp; x</p>.

Scenario: undefined attr skipped

  • WHEN renderVNode(h("a", { href: "https://x", title: undefined }, "L")) is called
  • THEN the output contains href="https://x" and does NOT contain the word title.

Scenario: style object inlined

  • WHEN renderVNode(h("p", { style: { color: "red", padding: "4px" } }, "x")) is called
  • THEN the output contains style="color:red;padding:4px".

Scenario: self-closing img

  • WHEN renderVNode(h("img", { src: "a.png", alt: "x" }, [], { selfClosing: true })) is called
  • THEN the output equals <img src="a.png" alt="x" />.

Verified by: src/vnode/vnode.test.ts::"escapes text content" AND src/vnode/vnode.test.ts::"skips undefined attributes" AND src/vnode/vnode.test.ts::"inlines style object" AND src/vnode/vnode.test.ts::"emits self-closing img".


Requirement: Theme system

The package SHALL export defaultTheme: EmailTheme, createTheme(options?), and mergeThemes(base, override). defaultTheme.colors.primary === "#2563eb". createTheme MUST deep-merge into the defaults. mergeThemes MUST shallow-merge each section (colors / typography / spacing / branding) into the base.

Scenario: defaults are present

  • WHEN defaultTheme.spacing.md is read
  • THEN the value equals "16px".

Scenario: custom theme override

  • WHEN createTheme({ colors: { primary: "#ff0000" } }).colors.primary is read
  • THEN the value equals "#ff0000" and defaultTheme.colors.primary is unchanged.

Verified by: src/theme/theme.test.ts::"exposes default spacing" AND src/theme/theme.test.ts::"createTheme overrides colors".


Requirement: EmailLayout component

The package SHALL export EmailLayout(props) returning a VNode rendering a <table> wrapper with the theme's muted background, a centred max-width inner <table>, and an optional preview-text <div> with display:none;max-height:0;overflow:hidden.

Scenario: preview block rendered

  • WHEN renderVNode(EmailLayout({ preview: "Hi", children: "body" })) is parsed by htmlparser2
  • THEN a <div> with style containing display:none whose text equals Hi is present.

Verified by: src/components/email-layout/email-layout.test.ts::"emits preview block when preview prop set".


Requirement: EmailContainer component

The package SHALL export EmailContainer(props) rendering a <table> with a single <td> whose padding defaults to theme.spacing.md, optional background, and align defaulting to "left".

Scenario: background applied

  • WHEN renderVNode(EmailContainer({ background: "#f00", children: "x" })) is parsed
  • THEN the rendered <td> has style containing background-color:#f00 (or backgroundColor:#f00).

Verified by: src/components/email-container/email-container.test.ts::"applies background colour".


Requirement: EmailHeading component

EmailHeading(props) SHALL render an <h1>…<h6> element matching props.level (default 1), with theme-aware font sizing keyed by level. Invalid levels (< 1, > 6, non-integer) MUST throw EmailTemplatesError(INVALID_HEADING_LEVEL).

Scenario: level 2 renders h2

  • WHEN renderVNode(EmailHeading({ level: 2, children: "Title" })) is parsed
  • THEN the root element name equals h2 and its text equals Title.

Scenario: invalid level rejected

  • WHEN EmailHeading({ level: 7 as any, children: "x" }) is called
  • THEN the call throws EmailTemplatesError with code === EMAIL_TEMPLATES.INVALID_HEADING_LEVEL.

Verified by: src/components/email-heading/email-heading.test.ts::"renders the requested heading level" AND src/components/email-heading/email-heading.test.ts::"rejects invalid level".


Requirement: EmailText component

EmailText(props) SHALL render a <p> element. Variants (body / caption / lead) MUST set distinct font-size + line-height. muted = true MUST use theme.colors.mutedForeground.

Scenario: caption variant size

  • WHEN renderVNode(EmailText({ variant: "caption", children: "x" })) is parsed
  • THEN the <p> has style containing 13px.

Verified by: src/components/email-text/email-text.test.ts::"applies caption variant size".


EmailLink(props) SHALL render an <a> with target="_blank", rel="noopener noreferrer", and text-decoration: underline by default. underline: false MUST emit text-decoration: none.

Scenario: rel attribute

  • WHEN renderVNode(EmailLink({ href: "https://x", children: "x" })) is parsed
  • THEN the <a> has rel="noopener noreferrer".

Verified by: src/components/email-link/email-link.test.ts::"sets safe rel attribute".


Requirement: EmailImage component

EmailImage(props) SHALL render a wrapping <table> plus <img> with display:block, max-width:100%, and border:0. alt is required.

Scenario: alt rendered

  • WHEN renderVNode(EmailImage({ src: "a.png", alt: "logo" })) is parsed
  • THEN the <img> has alt="logo".

Verified by: src/components/email-image/email-image.test.ts::"renders alt attribute".


Requirement: EmailDivider component

EmailDivider(props) SHALL render a <table> whose <td> carries border-top: 1px solid with the configured colour (default theme.colors.border).

Scenario: colour override

  • WHEN renderVNode(EmailDivider({ color: "#abcdef" })) is parsed
  • THEN the <td> style contains 1px solid #abcdef.

Verified by: src/components/email-divider/email-divider.test.ts::"applies colour override".


Requirement: EmailButton component

EmailButton(props) SHALL render a <table> containing an <a> styled as a CTA. Variants (primary / secondary / outline) MUST produce distinct background / border combinations. Sizes (sm / md / lg) MUST select distinct padding + font-size. fullWidth MUST emit width: 100%. Invalid variant or size MUST throw EmailTemplatesError(INVALID_OPTIONS).

Scenario: outline variant has transparent background

  • WHEN renderVNode(EmailButton({ href: "https://x", children: "Go", variant: "outline" })) is parsed
  • THEN the <a> style contains transparent.

Scenario: invalid variant rejected

  • WHEN EmailButton({ href: "https://x", children: "x", variant: "ghost" as any }) is called
  • THEN the call throws EmailTemplatesError with code === EMAIL_TEMPLATES.INVALID_OPTIONS.

Verified by: src/components/email-button/email-button.test.ts::"renders outline variant" AND src/components/email-button/email-button.test.ts::"rejects invalid variant".


Requirement: EmailFooter component with injectable clock

EmailFooter(props) SHALL render a footer block with company name, optional address, optional link row, and an optional unsubscribe link. The copyright year MUST be derived from a clock: Clock prop (default: real wall-clock); the footer MUST NOT call new Date() at module scope.

Scenario: clock controls year

  • WHEN renderVNode(EmailFooter({ clock: { now: () => 1735689600000 } as Clock })) is parsed (i.e. 2025-01-01)
  • THEN the footer text contains 2025.
  • WHEN renderVNode(EmailFooter({ unsubscribeUrl: "https://x/unsub" })) is parsed
  • THEN an <a> with href="https://x/unsub" whose text equals Unsubscribe is present.

Verified by: src/components/email-footer/email-footer.test.ts::"derives year from injected clock" AND src/components/email-footer/email-footer.test.ts::"emits unsubscribe link".


Requirement: Renderer helpers

The package SHALL export escapeHtml, stripHtml, resolveSubject, wrapInDocument, and renderToHtml. wrapInDocument(body, { title, previewText, lang, dir }) MUST emit <!DOCTYPE html> + <html lang="..." dir="..."> + a <head> with meta charset="utf-8", viewport meta, X-UA-Compatible meta, MSO conditional comment, and <title>. renderToHtml(template, props, options) MUST compose the template's VNode through wrapInDocument, produce a RenderResult { html, text, subject }, and emit OTel render counter + duration histogram tagged by template and outcome. Optional minify: true MUST collapse whitespace between tags.

Scenario: doctype emitted

  • WHEN wrapInDocument("<p>x</p>") is called
  • THEN the output starts with <!DOCTYPE html>.

Scenario: end-to-end render of welcome template

  • WHEN renderToHtml(welcomeTemplate, { recipientName: "Jane", ctaUrl: "https://x" }) is called
  • THEN the result's html parses to a document with an <h1> whose text contains Welcome, the text is non-empty, and the subject contains Welcome.

Verified by: src/renderer/renderer.test.ts::"wrapInDocument emits doctype" AND src/renderer/renderer.test.ts::"renderToHtml composes welcome template".


Requirement: TemplateRegistry

TemplateRegistry SHALL provide register, get, has, list, search, remove, clear, and a size getter. Registering with an empty name MUST throw EmailTemplatesError(INVALID_TEMPLATE). search({ name, category, tags, author }) MUST match by partial name (case-insensitive), exact category, any-tag intersection, and exact author. register() MUST emit an OTel counter tagged by outcome.

Scenario: register and retrieve

  • WHEN r.register("welcome", welcomeTemplate); r.get("welcome") is called
  • THEN the returned value is welcomeTemplate.

Scenario: search by tag

  • WHEN two templates are registered with tags: ["a"] and tags: ["b"] and r.search({ tags: ["b"] }) is called
  • THEN the result has length 1.

Verified by: src/template-registry/template-registry.test.ts::"registers and retrieves a template" AND src/template-registry/template-registry.test.ts::"filters search by tag".


Requirement: TemplateBuilder

TemplateBuilder<T> SHALL provide fluent name, subject, component, defaultProps, and build methods. build() MUST throw EmailTemplatesError(INVALID_TEMPLATE) if name, subject, or component is missing.

Scenario: incomplete build rejected

  • WHEN createTemplateBuilder<{ a: string }>().name("x").build() is called
  • THEN the call throws EmailTemplatesError with code === EMAIL_TEMPLATES.INVALID_TEMPLATE.

Verified by: src/template-builder/template-builder.test.ts::"rejects incomplete build".


Requirement: createEmailMessage adapter

createEmailMessage(template, props, recipient, options?) SHALL combine the render result with the recipient envelope returning { to, from?, cc?, bcc?, replyTo?, subject, html, text }.

Scenario: recipient + render merge

  • WHEN createEmailMessage(welcomeTemplate, { recipientName: "J", ctaUrl: "https://x" }, { to: "j@x.test", from: "me@x.test" }) is called
  • THEN the result .to === "j@x.test", .from === "me@x.test", and .subject contains Welcome.

Verified by: src/message/message.test.ts::"merges recipient envelope with render result".


Requirement: Five built-in templates

The package SHALL export welcomeTemplate, verificationTemplate, passwordResetTemplate, notificationTemplate, invoiceTemplate. Each MUST be a complete EmailTemplate<TProps> with name, subject, and component. Rendering each one with valid props MUST produce a parseable HTML document.

Scenario: welcome renders with CTA

  • WHEN renderToHtml(welcomeTemplate, { recipientName: "Jane", ctaUrl: "https://x" }) is parsed
  • THEN an <a> with href="https://x" is present in the rendered document.

Scenario: invoice renders line items

  • WHEN renderToHtml(invoiceTemplate, { recipientName: "J", invoiceNumber: "001", invoiceDate: "2026-01-01", items: [{ description: "Widget", quantity: 2, unitPrice: 10, total: 20 }], subtotal: 20, total: 20 }) is parsed
  • THEN the rendered document contains a <td> whose text equals Widget.

Scenario: verification renders code block

  • WHEN renderToHtml(verificationTemplate, { recipientName: "J", verificationUrl: "https://x", code: "ABC123" }) is parsed
  • THEN a <p> whose text contains ABC123 is present.

Scenario: notification level styling

  • WHEN renderToHtml(notificationTemplate, { recipientName: "J", title: "Heads up", message: "x", level: "warning" }) is parsed
  • THEN the rendered document contains the warning palette colour (e.g. #d97706 or its background partner #fffbeb).

Scenario: password reset includes expiry

  • WHEN renderToHtml(passwordResetTemplate, { recipientName: "J", resetUrl: "https://x", expiresInMinutes: 30 }) is parsed
  • THEN the rendered document contains the substring 30 minutes.

Verified by: src/templates/welcome/welcome.test.ts::"renders CTA link" AND src/templates/invoice/invoice.test.ts::"renders line-item rows" AND src/templates/verification/verification.test.ts::"renders verification code" AND src/templates/notification/notification.test.ts::"applies level palette" AND src/templates/password-reset/password-reset.test.ts::"renders expiry notice".


Requirement: OTel instruments

The package SHALL lazily construct a meter @suaveplan/email-templates exposing render_total, render_duration_ms, registry_register_total, and validation_failure_total instruments. The instrument accessor MUST cache and return the same instance on repeated calls.

Scenario: instruments cached

  • WHEN getEmailTemplatesInstruments() is called twice
  • THEN both calls return the same object reference.

Verified by: src/telemetry/telemetry.test.ts::"caches the instrument set".