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_TEMPLATEis 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 < 2 & 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 wordtitle.
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.mdis read - THEN the value equals
"16px".
Scenario: custom theme override
- WHEN
createTheme({ colors: { primary: "#ff0000" } }).colors.primaryis read - THEN the value equals
"#ff0000"anddefaultTheme.colors.primaryis 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>withstylecontainingdisplay:nonewhose text equalsHiis 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>hasstylecontainingbackground-color:#f00(orbackgroundColor:#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
h2and its text equalsTitle.
Scenario: invalid level rejected
- WHEN
EmailHeading({ level: 7 as any, children: "x" })is called - THEN the call throws
EmailTemplatesErrorwithcode === 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>hasstylecontaining13px.
Verified by: src/components/email-text/email-text.test.ts::"applies caption variant size".
Requirement: EmailLink component
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>hasrel="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>hasalt="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 contains1px 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 containstransparent.
Scenario: invalid variant rejected
- WHEN
EmailButton({ href: "https://x", children: "x", variant: "ghost" as any })is called - THEN the call throws
EmailTemplatesErrorwithcode === 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.
Scenario: unsubscribe link present
- WHEN
renderVNode(EmailFooter({ unsubscribeUrl: "https://x/unsub" }))is parsed - THEN an
<a>withhref="https://x/unsub"whose text equalsUnsubscribeis 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
htmlparses to a document with an<h1>whose text containsWelcome, thetextis non-empty, and thesubjectcontainsWelcome.
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"]andtags: ["b"]andr.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
EmailTemplatesErrorwithcode === 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.subjectcontainsWelcome.
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>withhref="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 equalsWidget.
Scenario: verification renders code block
- WHEN
renderToHtml(verificationTemplate, { recipientName: "J", verificationUrl: "https://x", code: "ABC123" })is parsed - THEN a
<p>whose text containsABC123is 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.
#d97706or 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".