313 lines
16 KiB
Markdown
313 lines
16 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: email-templates
|
|
|
|
**Package**: `@suaveplan/email-templates`
|
|
**Status**: PROPOSED
|
|
|
|
> 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 < 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 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"`.
|
|
|
|
---
|
|
|
|
### 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>` 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`.
|
|
|
|
#### Scenario: unsubscribe link present
|
|
|
|
- **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"`.
|