Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
# @suaveplan/email-templates - Universal React-free email template engine: VNode factories, nine reusable components, five built-in templates (welcome, verification, password-reset, notification, invoice), typed registry, fluent builder, theme system, structured errors, OTel instruments. Produces email-safe HTML strings with inline styles; runs in Bun, Node, and browser.
Universal, React-free email template engine. Produces email-safe HTML
strings with inline styles via VNode factories — runs identically in Bun,
Node, and the browser.
<!-- begin:auto-docs -->
## Documentation tree
- [email-button](./src/components/email-button/email-button.md) — `EmailButton` is the canonical call-to-action surface in an email body — a table-wrapped `<a>` with variant-driven them…
- [email-button](./src/components/email-button/email-button.md) — `EmailButton` is the canonical call-to-action surface in an email body — a table-wrapped `<a>` with variant-driven them…
- [email-container](./src/components/email-container/email-container.md) — `EmailContainer` groups related content inside an email body — a callout box, an info banner, a code-card. It emits a s…
- [email-container](./src/components/email-container/email-container.md) — `EmailContainer` groups related content inside an email body — a callout box, an info banner, a code-card. It emits a s…
- [email-divider](./src/components/email-divider/email-divider.md) — A horizontal divider rendered as a single-row `<table>` whose `<td>` carries `border-top: 1px solid <color>`. The table…
- [email-divider](./src/components/email-divider/email-divider.md) — A horizontal divider rendered as a single-row `<table>` whose `<td>` carries `border-top: 1px solid <color>`. The table…
- [email-footer](./src/components/email-footer/email-footer.md) — Renders the email footer block — company copyright, optional address, optional list of inline links (typically Privacy…
- [email-footer](./src/components/email-footer/email-footer.md) — Renders the email footer block — company copyright, optional address, optional list of inline links (typically Privacy…
- [email-heading](./src/components/email-heading/email-heading.md) — Renders an `<h1>`…`<h6>` element with a level-keyed font-size and line-height schedule tuned for inbox-rendering ergono…
- [email-heading](./src/components/email-heading/email-heading.md) — Renders an `<h1>`…`<h6>` element with a level-keyed font-size and line-height schedule tuned for inbox-rendering ergono…
- [email-image](./src/components/email-image/email-image.md) — Renders an `<img>` inside a centring `<table>` with the canonical responsive-email defaults (`display:block`, `max-widt…
- [email-image](./src/components/email-image/email-image.md) — Renders an `<img>` inside a centring `<table>` with the canonical responsive-email defaults (`display:block`, `max-widt…
- [email-layout](./src/components/email-layout/email-layout.md) — `EmailLayout` is the root VNode every email template wraps its body in. It emits a 100%-width `<table>` with a themed m…
- [email-layout](./src/components/email-layout/email-layout.md) — `EmailLayout` is the root VNode every email template wraps its body in. It emits a 100%-width `<table>` with a themed m…
- [email-link](./src/components/email-link/email-link.md) — Themed inline `<a>` link with safe defaults (`target="_blank"`, `rel="noopener noreferrer"`) for body-copy hyperlinks.…
- [email-link](./src/components/email-link/email-link.md) — Themed inline `<a>` link with safe defaults (`target="_blank"`, `rel="noopener noreferrer"`) for body-copy hyperlinks.…
- [email-text](./src/components/email-text/email-text.md) — Paragraph component for email-body copy. `EmailText` emits a themed `<p>` with one of three size variants (`body` / `ca…
- [email-text](./src/components/email-text/email-text.md) — Paragraph component for email-body copy. `EmailText` emits a themed `<p>` with one of three size variants (`body` / `ca…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/email-templates`. Every failure raised by the package extends `SuaveplanErro…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/email-templates`. Every failure raised by the package extends `SuaveplanErro…
- [message](./src/message/message.md) — `createEmailMessage` glues the renderer to the addressing envelope, producing an `EmailMessageResult` plain object suit…
- [message](./src/message/message.md) — `createEmailMessage` glues the renderer to the addressing envelope, producing an `EmailMessageResult` plain object suit…
- [renderer](./src/renderer/renderer.md) — The renderer is the bridge between the `EmailTemplate<TProps>` contract and a final `{ html, text, subject }` triple re…
- [renderer](./src/renderer/renderer.md) — The renderer is the bridge between the `EmailTemplate<TProps>` contract and a final `{ html, text, subject }` triple re…
- [telemetry](./src/telemetry/telemetry.md) — Exposes the OpenTelemetry instrument set published by `@suaveplan/email-templates`. Every public render or registry mut…
- [telemetry](./src/telemetry/telemetry.md) — Exposes the OpenTelemetry instrument set published by `@suaveplan/email-templates`. Every public render or registry mut…
- [template-builder](./src/template-builder/template-builder.md) — `TemplateBuilder` is a fluent API for assembling an `EmailTemplate<TProps>` value step-by-step. It exists because const…
- [template-builder](./src/template-builder/template-builder.md) — `TemplateBuilder` is a fluent API for assembling an `EmailTemplate<TProps>` value step-by-step. It exists because const…
- [template-registry](./src/template-registry/template-registry.md) — `TemplateRegistry` is a type-safe in-memory store for `EmailTemplate` values keyed by name. It supports per-name lookup…
- [template-registry](./src/template-registry/template-registry.md) — `TemplateRegistry` is a type-safe in-memory store for `EmailTemplate` values keyed by name. It supports per-name lookup…
- [invoice](./src/templates/invoice/invoice.md) — The `invoiceTemplate` is the receipt / invoice / order-confirmation email. It renders a heading carrying the invoice nu…
- [invoice](./src/templates/invoice/invoice.md) — The `invoiceTemplate` is the receipt / invoice / order-confirmation email. It renders a heading carrying the invoice nu…
- [notification](./src/templates/notification/notification.md) — The `notificationTemplate` is the generic system-notification email — a configurable severity callout suitable for prod…
- [notification](./src/templates/notification/notification.md) — The `notificationTemplate` is the generic system-notification email — a configurable severity callout suitable for prod…
- [password-reset](./src/templates/password-reset/password-reset.md) — The `passwordResetTemplate` is the canonical password-reset message. It explains the request, exposes a reset CTA, comm…
- [password-reset](./src/templates/password-reset/password-reset.md) — The `passwordResetTemplate` is the canonical password-reset message. It explains the request, exposes a reset CTA, comm…
- [verification](./src/templates/verification/verification.md) — The `verificationTemplate` is the standard email-address verification flow message. It greets the recipient, optionally…
- [verification](./src/templates/verification/verification.md) — The `verificationTemplate` is the standard email-address verification flow message. It greets the recipient, optionally…
- [welcome](./src/templates/welcome/welcome.md) — The `welcomeTemplate` is the canonical post-signup onboarding email. It greets the recipient by name, summarises why th…
- [welcome](./src/templates/welcome/welcome.md) — The `welcomeTemplate` is the canonical post-signup onboarding email. It greets the recipient by name, summarises why th…
- [theme](./src/theme/theme.md) — Provides the theme tokens consumed by every email component — colours, typography, spacing, branding, border-radius, an…
- [theme](./src/theme/theme.md) — Provides the theme tokens consumed by every email component — colours, typography, spacing, branding, border-radius, an…
- [vnode](./src/vnode/vnode.md) — Defines the universal VNode primitive — a `{ tag, attrs, children }` triple plus a pure-string HTML renderer — tha…
- [vnode](./src/vnode/vnode.md) — Defines the universal VNode primitive — a `{ tag, attrs, children }` triple plus a pure-string HTML renderer — tha…
<!-- end:auto-docs -->
## What's included
- **Nine reusable components** — `EmailLayout`, `EmailContainer`,
`EmailHeading`, `EmailText`, `EmailLink`, `EmailImage`, `EmailDivider`,
`EmailButton`, `EmailFooter`.
- **Five built-in templates** — `welcomeTemplate`, `verificationTemplate`,
`passwordResetTemplate`, `notificationTemplate`, `invoiceTemplate`.
- **VNode core** — `h(tag, attrs, children)` hyperscript factory and a
pure-string `renderVNode` walker.
- **Renderer** — `renderToHtml(template, props, options?)` → `{ html, text, subject }`
with OTel instrumentation.
- **Theme system** — `defaultTheme`, `createTheme`, `mergeThemes`.
- **Type-safe registry** — `TemplateRegistry<TMap>` with category / tag /
author search.
- **Fluent builder** — `TemplateBuilder<TProps>` / `createTemplateBuilder<TProps>()`.
- **Adapter** — `createEmailMessage(template, props, recipient, options?)`
returning a plain-object envelope ready for SMTP / SES / SendGrid.
- **Structured errors** — `EmailTemplatesError` with six `EMAIL_TEMPLATES.*` codes.
## Quick start
```ts
import {
createEmailMessage,
welcomeTemplate,
} from "@suaveplan/email-templates";
const message = createEmailMessage(
welcomeTemplate,
{ recipientName: "Jane", ctaUrl: "https://app.example.test/get-started" },
{ to: "jane@example.test", from: "hello@suaveplan.com" },
);
await mailer.send(message);
```
## Architecture
Upstream `@suaveplan/email-templates` was a React component library
rendered via `react-dom/server`. The port honours the enterprise
spec §3 ("Zero browser / Node / React dependencies") by re-implementing
the full upstream surface as **VNode factories** — pure functions
returning `{ tag, attrs, children }` triples walked by a single
HTML-emitter (`renderVNode`).
The renderer is the sole point at which child text is HTML-escaped and
attributes are serialised. Components emit data; escaping happens once.
## Universal
- Zero React / DOM / Node-specific dependencies at runtime.
- Injectable `Clock` (`@suaveplan/types`) for deterministic footer year.
- OTel instruments via `@opentelemetry/api`.
- Pure-TypeScript implementation, 100% statement / branch / function /
line coverage.
## Sub-path exports
`@suaveplan/email-templates/vnode`, `…/theme`, `…/renderer`, `…/message`,
`…/template-builder`, `…/template-registry`, `…/components`, `…/templates`,
`…/errors`, `…/otel` — each exposes a focused slice of the surface for
tree-shake friendliness.

View File

@@ -0,0 +1,121 @@
{
"name": "@suaveplan/email-templates",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal React-free email template engine: VNode factories, nine reusable components, five built-in templates (welcome, verification, password-reset, notification, invoice), typed registry, fluent builder, theme system, structured errors, OTel instruments. Produces email-safe HTML strings with inline styles; runs in Bun, Node, and browser.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./vnode": {
"import": {
"types": "./dist/vnode/index.d.ts",
"default": "./dist/vnode/index.js"
}
},
"./theme": {
"import": {
"types": "./dist/theme/index.d.ts",
"default": "./dist/theme/index.js"
}
},
"./renderer": {
"import": {
"types": "./dist/renderer/index.d.ts",
"default": "./dist/renderer/index.js"
}
},
"./message": {
"import": {
"types": "./dist/message/index.d.ts",
"default": "./dist/message/index.js"
}
},
"./template-builder": {
"import": {
"types": "./dist/template-builder/index.d.ts",
"default": "./dist/template-builder/index.js"
}
},
"./template-registry": {
"import": {
"types": "./dist/template-registry/index.d.ts",
"default": "./dist/template-registry/index.js"
}
},
"./components": {
"import": {
"types": "./dist/components/index.d.ts",
"default": "./dist/components/index.js"
}
},
"./templates": {
"import": {
"types": "./dist/templates/index.d.ts",
"default": "./dist/templates/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./otel": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"domhandler": "catalog:parsing",
"domutils": "catalog:parsing",
"htmlparser2": "catalog:parsing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,92 @@
# email-button
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmailButton` is the canonical call-to-action surface in an email body —
a table-wrapped `<a>` with variant-driven theme colours and three preset
sizes. The table wrapper is non-optional: Outlook on Windows uses a
Word-derived rendering engine that historically ignores `padding` on
inline anchors, so the only reliable way to ship a visually-correct CTA
into Outlook desktop is a `<table>` containing a centred `<td>` containing
the styled `<a>`.
The component centralises the variant + size recipe so callers don't
duplicate it at every CTA site, and validates `variant`/`size` against
the published enums at call time — typos turn into a typed
`EmailTemplatesError(INVALID_OPTIONS)` instead of a silently-malformed
button.
## Features
- Three variants — `primary`, `secondary`, `outline` — each pulling
colours from the active theme.
- Three sizes — `sm`, `md`, `lg` — each driving distinct padding +
font-size.
- `fullWidth` toggle emits `width: 100%` on both the wrapping `<table>`
and the `<a>` so the CTA spans its container.
- `align` prop emitted as the table's `align` attribute.
- Validation: unknown variant or size raises
`EmailTemplatesError(INVALID_OPTIONS)` and bumps an OTel counter
tagged `reason="invalid_button_variant"` or `"invalid_button_size"`.
- Safe link defaults (`target="_blank"`, `rel="noopener noreferrer"`).
## Usage: Basic
```ts
import { EmailButton } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailButton({
href: "https://app.example.test/get-started",
children: "Get Started",
}));
```
## Usage: Advanced
A full-width secondary CTA with custom letter-spacing:
```ts
import { EmailButton } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailButton({
href: "https://app.example.test/upgrade",
children: "Upgrade Now",
variant: "secondary",
size: "lg",
fullWidth: true,
style: { "letter-spacing": "0.5px" },
}));
```
## API Reference
| Prop | Type | Description |
| ----------- | --------------------------------- | ---------------------------------------------------- |
| `href` | `string` | CTA target URL. |
| `children` | `string` | Button label (string only — keeps the table cell-tight). |
| `theme` | `EmailTheme?` | Theme override. |
| `variant` | `"primary" \| "secondary" \| "outline"` | Defaults to `primary`. |
| `size` | `"sm" \| "md" \| "lg"` | Defaults to `md`. |
| `fullWidth` | `boolean?` | Defaults to `false`. |
| `align` | `"left" \| "center" \| "right"` | Outer table align; defaults to `"center"`. |
| `style` | `StyleObject?` | Additional anchor-level inline styles. |
## Implementation Notes
The variant style returns three keys (`background-color`, `color`,
`border`) plus the size returns two (`padding`, `font-size`); a switch
on the variant tag emits the right triple. Validation uses a `Set`
lookup — branch coverage is satisfied by one true-case test plus one
false-case test per validator.
The `box-sizing: border-box` declaration on the anchor ensures the
2 px border doesn't push the button over its intended outside width when
`fullWidth: true`. Without that declaration Outlook would render the
button slightly wider than its container in the `outline` variant where
the border weight matters most.

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst, textOf } from "../../../tests/setup/html.js";
import { EMAIL_TEMPLATES_CODES } from "../../error-codes/error-codes.js";
import { renderVNode } from "../../vnode/vnode.js";
import { EmailButton } from "./email-button.js";
import type { ButtonSize, ButtonVariant } from "./email-button.types.js";
describe("EmailButton", () => {
it("renders an anchor with the href and label", { timeout: 5000 }, () => {
const html = renderVNode(
EmailButton({ href: "https://x", children: "Go" })
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.href).toBe("https://x");
expect(textOf(a)).toBe("Go");
});
it("renders primary variant with themed background", {
timeout: 5000,
}, () => {
const html = renderVNode(
EmailButton({ href: "https://x", children: "Go" })
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain("#2563eb");
});
it("renders secondary variant", { timeout: 5000 }, () => {
const html = renderVNode(
EmailButton({
href: "https://x",
children: "Go",
variant: "secondary",
})
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain("#64748b");
});
it("renders outline variant", { timeout: 5000 }, () => {
const html = renderVNode(
EmailButton({
href: "https://x",
children: "Go",
variant: "outline",
})
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain("transparent");
});
it("applies size padding", { timeout: 5000 }, () => {
for (const [size, font] of [
["sm", "13px"],
["md", "15px"],
["lg", "17px"],
] as Array<[ButtonSize, string]>) {
const html = renderVNode(
EmailButton({ href: "https://x", children: "x", size })
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain(`font-size:${font}`);
}
});
it("rejects invalid variant", { timeout: 5000 }, () => {
let caught: unknown;
try {
EmailButton({
href: "https://x",
children: "x",
variant: "ghost" as unknown as ButtonVariant,
});
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_OPTIONS
);
});
it("rejects invalid size", { timeout: 5000 }, () => {
let caught: unknown;
try {
EmailButton({
href: "https://x",
children: "x",
size: "xl" as unknown as ButtonSize,
});
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_OPTIONS
);
});
it("emits width 100% when fullWidth", { timeout: 5000 }, () => {
const html = renderVNode(
EmailButton({ href: "https://x", children: "x", fullWidth: true })
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain("width:100%");
});
it("respects align attribute on the outer table", { timeout: 5000 }, () => {
const html = renderVNode(
EmailButton({ href: "https://x", children: "x", align: "left" })
);
const table = findFirst(html, (el) => el.name === "table");
expect(table?.attribs.align).toBe("left");
});
it("merges caller style into the anchor", { timeout: 5000 }, () => {
const html = renderVNode(
EmailButton({
href: "https://x",
children: "x",
style: { "letter-spacing": "1px" },
})
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain("letter-spacing:1px");
});
});

View File

@@ -0,0 +1,141 @@
/**
* CTA button rendered as a table-wrapped `<a>` with variant + size
* recipes drawn from the theme. Outlook-on-Windows honours the table
* geometry, which is why this is not a `<button>` element.
*/
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "../../error-codes/error-codes.js";
import { getEmailTemplatesInstruments } from "../../telemetry/telemetry.js";
import { defaultTheme } from "../../theme/theme.js";
import type { EmailTheme } from "../../theme/theme.types.js";
import { h } from "../../vnode/vnode.js";
import type { StyleObject, VNode } from "../../vnode/vnode.types.js";
import type {
ButtonSize,
ButtonVariant,
EmailButtonProps,
} from "./email-button.types.js";
const VARIANTS: ReadonlySet<ButtonVariant> = new Set([
"primary",
"secondary",
"outline",
]);
const SIZES: ReadonlySet<ButtonSize> = new Set(["sm", "md", "lg"]);
const SIZE_PADDING: Record<ButtonSize, string> = {
sm: "8px 16px",
md: "12px 24px",
lg: "16px 32px",
};
const SIZE_FONT: Record<ButtonSize, string> = {
sm: "13px",
md: "15px",
lg: "17px",
};
function variantStyle(variant: ButtonVariant, theme: EmailTheme): StyleObject {
switch (variant) {
case "primary":
return {
"background-color": theme.colors.primary,
color: theme.colors.primaryForeground,
border: `2px solid ${theme.colors.primary}`,
};
case "secondary":
return {
"background-color": theme.colors.secondary,
color: theme.colors.secondaryForeground,
border: `2px solid ${theme.colors.secondary}`,
};
case "outline":
return {
"background-color": "transparent",
color: theme.colors.primary,
border: `2px solid ${theme.colors.primary}`,
};
}
}
export function EmailButton(props: EmailButtonProps): VNode {
const {
href,
children,
theme = defaultTheme,
variant = "primary",
size = "md",
fullWidth = false,
align = "center",
style,
} = props;
if (!VARIANTS.has(variant)) {
getEmailTemplatesInstruments().validationFailureCounter.add(1, {
reason: "invalid_button_variant",
});
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_OPTIONS,
"email_templates.invalid_options.button_variant"
);
}
if (!SIZES.has(size)) {
getEmailTemplatesInstruments().validationFailureCounter.add(1, {
reason: "invalid_button_size",
});
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_OPTIONS,
"email_templates.invalid_options.button_size"
);
}
const v = variantStyle(variant, theme);
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
align,
style: {
margin: `0 0 ${theme.spacing.md} 0`,
width: fullWidth ? "100%" : undefined,
},
},
h(
"tr",
{},
h(
"td",
{ align: "center" },
h(
"a",
{
href,
target: "_blank",
rel: "noopener noreferrer",
style: {
display: "inline-block",
"text-decoration": "none",
"font-family": theme.typography.fontFamily,
"font-size": SIZE_FONT[size],
"font-weight": 600,
padding: SIZE_PADDING[size],
"border-radius": theme.borderRadius,
"text-align": "center",
width: fullWidth ? "100%" : undefined,
"box-sizing": "border-box",
...v,
...(style ?? {}),
},
},
children
)
)
)
);
}

View File

@@ -0,0 +1,16 @@
import type { EmailTheme } from "../../theme/theme.types.js";
import type { StyleObject } from "../../vnode/vnode.types.js";
export type ButtonVariant = "primary" | "secondary" | "outline";
export type ButtonSize = "sm" | "md" | "lg";
export interface EmailButtonProps {
readonly href: string;
readonly children: string;
readonly theme?: EmailTheme | undefined;
readonly variant?: ButtonVariant | undefined;
readonly size?: ButtonSize | undefined;
readonly fullWidth?: boolean | undefined;
readonly align?: "left" | "center" | "right" | undefined;
readonly style?: StyleObject | undefined;
}

View File

@@ -0,0 +1,6 @@
export { EmailButton } from "./email-button.js";
export type {
ButtonSize,
ButtonVariant,
EmailButtonProps,
} from "./email-button.types.js";

View File

@@ -0,0 +1,76 @@
# email-container
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmailContainer` groups related content inside an email body — a callout
box, an info banner, a code-card. It emits a single-row table wrapping a
themed `<td>` with configurable padding, background colour, alignment, and
optional style overrides. Containers stack vertically inside an
`EmailLayout`'s content cell.
The component is intentionally table-based: `display: flex` and CSS grid
do not render reliably in Outlook 2007–2019, so the canonical email-design
recipe is a `<table role="presentation">` per group with explicit
`cellpadding`/`cellspacing` zeroed.
## Features
- Configurable `padding` (defaults to `theme.spacing.md`), `background`,
`align` (`left` / `center` / `right`), and arbitrary `style` overrides.
- Themed border-radius emitted on the `<td>`.
- `width: 100%` so containers fill their parent cell.
- Margin-bottom of `theme.spacing.md` baked in so stacked containers
retain breathing room without each caller adding spacing manually.
## Usage: Basic
```ts
import { EmailContainer } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailContainer({ background: "#fff7ed", children: "Heads up!" }));
```
## Usage: Advanced
Tinted callout with custom padding and a left accent border:
```ts
import { EmailContainer } from "@suaveplan/email-templates/components";
import { EmailText } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailContainer({
background: "#fffbeb",
padding: "24px",
style: { "border-left": "4px solid #d97706" },
children: EmailText({ children: "Action required: verify your email." }),
}));
```
## API Reference
| Prop | Type | Description |
| ------------- | --------------------------------- | -------------------------------------------------------- |
| `children` | `EmailChildren?` | Inner content. |
| `theme` | `EmailTheme?` | Theme override. |
| `padding` | `string?` | CSS padding; defaults to `theme.spacing.md`. |
| `background` | `string?` | CSS `background-color`. |
| `align` | `"left" \| "center" \| "right"` | Cell `align` attribute; defaults to `"left"`. |
| `style` | `StyleObject?` | Additional inline styles merged into the `<td>`. |
## Implementation Notes
Caller-supplied `style` is merged **after** the component's own style
keys, so it can override any of them. This is intentional: a caller who
needs `border-radius: 0` (e.g. a full-width accent strip) should be able
to override the themed default without forking the component.
The wrapping `<table>` carries `role="presentation"` to mark it as
layout-only for assistive tech. The `<td>`'s `align` attribute (not just
`text-align`) is set because Outlook honours the legacy attribute more
reliably than the CSS shorthand inside a cell context.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst } from "../../../tests/setup/html.js";
import { renderVNode } from "../../vnode/vnode.js";
import { EmailContainer } from "./email-container.js";
describe("EmailContainer", () => {
it("applies background colour", { timeout: 5000 }, () => {
const html = renderVNode(
EmailContainer({ background: "#f00", children: "x" })
);
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.style ?? "").toContain("background-color:#f00");
});
it("uses default padding from theme.spacing.md", { timeout: 5000 }, () => {
const html = renderVNode(EmailContainer({ children: "x" }));
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.style ?? "").toContain("padding:16px");
});
it("uses explicit padding when supplied", { timeout: 5000 }, () => {
const html = renderVNode(
EmailContainer({ children: "x", padding: "40px" })
);
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.style ?? "").toContain("padding:40px");
});
it("defaults align to left", { timeout: 5000 }, () => {
const html = renderVNode(EmailContainer({ children: "x" }));
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.align).toBe("left");
});
it("respects align override", { timeout: 5000 }, () => {
const html = renderVNode(
EmailContainer({ children: "x", align: "center" })
);
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.align).toBe("center");
});
it("merges caller style into the td", { timeout: 5000 }, () => {
const html = renderVNode(
EmailContainer({
children: "x",
style: { border: "2px dashed red" },
})
);
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.style ?? "").toContain("border:2px dashed red");
});
it("renders an empty cell when children omitted", { timeout: 5000 }, () => {
const html = renderVNode(EmailContainer({}));
const td = findFirst(html, (el) => el.name === "td");
expect(td).not.toBeNull();
});
});

View File

@@ -0,0 +1,50 @@
/**
* Section container — a themed cell with configurable padding,
* background and alignment for grouping related content inside a layout.
*/
import { defaultTheme } from "../../theme/theme.js";
import { h } from "../../vnode/vnode.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type { EmailContainerProps } from "./email-container.types.js";
export function EmailContainer(props: EmailContainerProps): VNode {
const {
children,
theme = defaultTheme,
padding,
background,
align = "left",
style,
} = props;
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: {
width: "100%",
margin: `0 0 ${theme.spacing.md} 0`,
},
},
h(
"tr",
{},
h(
"td",
{
align,
style: {
padding: padding ?? theme.spacing.md,
"background-color": background,
"border-radius": theme.borderRadius,
...(style ?? {}),
},
},
children ?? []
)
)
);
}

View File

@@ -0,0 +1,11 @@
import type { EmailTheme } from "../../theme/theme.types.js";
import type { EmailChildren, StyleObject } from "../../vnode/vnode.types.js";
export interface EmailContainerProps {
readonly children?: EmailChildren | undefined;
readonly theme?: EmailTheme | undefined;
readonly padding?: string | undefined;
readonly background?: string | undefined;
readonly align?: "left" | "center" | "right" | undefined;
readonly style?: StyleObject | undefined;
}

View File

@@ -0,0 +1,2 @@
export { EmailContainer } from "./email-container.js";
export type { EmailContainerProps } from "./email-container.types.js";

View File

@@ -0,0 +1,66 @@
# email-divider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A horizontal divider rendered as a single-row `<table>` whose `<td>`
carries `border-top: 1px solid <color>`. The table-with-border-top
approach renders consistently across every email client — Outlook,
Gmail, Apple Mail, Outlook.com, mobile clients — while a plain `<hr>`
historically produced wildly different visual weights (some clients
emitting a 1 px line, others a 2 px etched groove, still others a 3D
ridge).
## Features
- Themed colour by default (`theme.colors.border`); per-divider override
via the `color` prop.
- Margin top + bottom of `theme.spacing.md` baked in.
- 1 px collapse-safe sizing (`font-size: 1px`, `line-height: 1px`)
prevents the empty cell from inflating the divider thickness in
clients that auto-fill empty `<td>` with the inherited font-size.
## Usage: Basic
```ts
import { EmailDivider } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailDivider({}));
```
## Usage: Advanced
A coloured divider with extra top margin to separate two sections:
```ts
import { EmailDivider } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailDivider({
color: "#22d3ee",
style: { "margin-top": "40px", "margin-bottom": "40px" },
}));
```
## API Reference
| Prop | Type | Description |
| ------- | -------------- | -------------------------------------------------------- |
| `theme` | `EmailTheme?` | Theme override (border colour, spacing). |
| `color` | `string?` | Explicit divider colour; overrides `theme.colors.border`. |
| `style` | `StyleObject?` | Additional `<td>` inline styles. |
## Implementation Notes
The cell contains a single non-breaking space character (rendered as a
literal space) to satisfy the few clients that collapse empty `<td>`
into zero-height boxes. Combined with the 1 px font-size + line-height,
the cell renders as the border alone — exactly 1 px tall.
The `<table>` wrapper uses `width: 100%` so the divider spans whatever
column it lives in. Vertical margins come from the table, not the cell,
so the divider sits inside the layout's vertical rhythm correctly.

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst } from "../../../tests/setup/html.js";
import { renderVNode } from "../../vnode/vnode.js";
import { EmailDivider } from "./email-divider.js";
describe("EmailDivider", () => {
it("uses theme border colour by default", { timeout: 5000 }, () => {
const html = renderVNode(EmailDivider({}));
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.style ?? "").toContain("1px solid #e2e8f0");
});
it("applies colour override", { timeout: 5000 }, () => {
const html = renderVNode(EmailDivider({ color: "#abcdef" }));
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.style ?? "").toContain("1px solid #abcdef");
});
it("emits the 1px collapse-safe sizing", { timeout: 5000 }, () => {
const html = renderVNode(EmailDivider({}));
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.style ?? "").toContain("font-size:1px");
expect(td?.attribs.style ?? "").toContain("line-height:1px");
});
it("merges caller style", { timeout: 5000 }, () => {
const html = renderVNode(
EmailDivider({ style: { "margin-top": "40px" } })
);
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.style ?? "").toContain("margin-top:40px");
});
});

View File

@@ -0,0 +1,42 @@
/**
* Horizontal rule rendered as a table-with-border-top so it renders
* consistently across Outlook + every webmail client.
*/
import { defaultTheme } from "../../theme/theme.js";
import { h } from "../../vnode/vnode.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type { EmailDividerProps } from "./email-divider.types.js";
export function EmailDivider(props: EmailDividerProps): VNode {
const { theme = defaultTheme, color, style } = props;
const lineColor = color ?? theme.colors.border;
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: {
width: "100%",
margin: `${theme.spacing.md} 0`,
},
},
h(
"tr",
{},
h(
"td",
{
style: {
"border-top": `1px solid ${lineColor}`,
"font-size": "1px",
"line-height": "1px",
...(style ?? {}),
},
},
" "
)
)
);
}

View File

@@ -0,0 +1,8 @@
import type { EmailTheme } from "../../theme/theme.types.js";
import type { StyleObject } from "../../vnode/vnode.types.js";
export interface EmailDividerProps {
readonly theme?: EmailTheme | undefined;
readonly color?: string | undefined;
readonly style?: StyleObject | undefined;
}

View File

@@ -0,0 +1,2 @@
export { EmailDivider } from "./email-divider.js";
export type { EmailDividerProps } from "./email-divider.types.js";

View File

@@ -0,0 +1,92 @@
# email-footer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Renders the email footer block — company copyright, optional address,
optional list of inline links (typically Privacy / Terms / Help), and an
optional unsubscribe link. The year is derived from an **injectable
`Clock`** so the module has no implicit `Date` dependency at the
component boundary — a critical property for snapshot-stable tests and
deterministic re-renders.
## Features
- Injectable `Clock` (defaults to a real `Date.now()` clock); the copyright
year is `new Date(clock.now()).getUTCFullYear()` so the value is
timezone-stable.
- Company name falls back to `theme.branding.companyName` when the prop
is omitted; explicit prop overrides the theme.
- Optional `address` paragraph and `unsubscribeUrl` anchor.
- Optional `links` list rendered as a centred paragraph separated by
` · ` mid-dots — Apple Mail and Gmail render the mid-dot reliably.
- Themed muted-foreground colour, top border, and padding.
- `children` slot for custom footer copy (rendered before the boilerplate).
## Usage: Basic
```ts
import { EmailFooter } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailFooter({
companyName: "Acme Corp",
unsubscribeUrl: "https://acme.test/unsub?token=abc",
}));
```
## Usage: Advanced
A footer with custom legal copy, link row, address, and an injected
clock for deterministic test output:
```ts
import { EmailFooter } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
const fakeClock = { now: () => 1735689600000 }; // 2025-01-01
renderVNode(EmailFooter({
clock: fakeClock,
companyName: "Acme Corp",
address: "1 Acme Way, Springfield, IL 62701, USA",
links: [
{ label: "Privacy", href: "https://acme.test/privacy" },
{ label: "Terms", href: "https://acme.test/terms" },
{ label: "Help", href: "https://acme.test/help" },
],
unsubscribeUrl: "https://acme.test/unsub?token=abc",
children: "You're receiving this because you opted in on Jan 1.",
}));
```
## API Reference
| Prop | Type | Description |
| ----------------- | --------------------------------- | ---------------------------------------------------- |
| `theme` | `EmailTheme?` | Theme override. |
| `clock` | `Clock?` | Injectable wall-clock for copyright year. |
| `companyName` | `string?` | Overrides `theme.branding.companyName`. |
| `address` | `string?` | Postal address line. |
| `unsubscribeUrl` | `string?` | If set, emit an Unsubscribe anchor. |
| `links` | `EmailFooterLink[]?` | Inline link row (Privacy / Terms / Help / …). |
| `children` | `EmailChildren?` | Custom copy rendered before the boilerplate block. |
| `style` | `StyleObject?` | Additional inline styles on the footer `<td>`. |
## Implementation Notes
`year` is computed via `new Date(clock.now()).getUTCFullYear()` —
`getUTCFullYear()` (not `getFullYear()`) is chosen so the rendered year
matches the UTC calendar day, which is what the test corpus asserts.
The choice avoids the edge case where a non-UTC server renders
`new Date()` near year-end and produces a `getFullYear()` value that
disagrees with the message header `Date:`.
The `children` slot accepts arbitrary `EmailChildren`. The
implementation re-wraps `children` into a passthrough VNode and lifts
its flattened children list into the footer cell — this handles the
array case (where `children` itself is `[node, node]`) without
re-implementing the flatten logic.

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import { findEvery, findFirst } from "../../../tests/setup/html.js";
import { renderVNode } from "../../vnode/vnode.js";
import { EmailFooter } from "./email-footer.js";
const fixedClock =
(epochMs: number): Clock =>
() =>
epochMs;
describe("EmailFooter", () => {
it("derives year from injected clock", { timeout: 5000 }, () => {
// 2025-01-01T00:00:00Z
const html = renderVNode(
EmailFooter({ clock: fixedClock(1735689600000) })
);
expect(html).toContain("2025");
});
it("uses real clock when omitted", { timeout: 5000 }, () => {
const html = renderVNode(EmailFooter({}));
const currentYear = new Date().getUTCFullYear();
expect(html).toContain(String(currentYear));
});
it("uses default company name from theme", { timeout: 5000 }, () => {
const html = renderVNode(EmailFooter({}));
expect(html).toContain("Company");
});
it("uses explicit company name when supplied", { timeout: 5000 }, () => {
const html = renderVNode(EmailFooter({ companyName: "Acme" }));
expect(html).toContain("Acme");
});
it("renders address when supplied", { timeout: 5000 }, () => {
const html = renderVNode(
EmailFooter({ address: "1 Acme Way, Springfield" })
);
expect(html).toContain("1 Acme Way, Springfield");
});
it("omits address when not supplied", { timeout: 5000 }, () => {
const html = renderVNode(EmailFooter({}));
expect(html).not.toContain("Acme Way");
});
it("emits unsubscribe link", { timeout: 5000 }, () => {
const html = renderVNode(
EmailFooter({ unsubscribeUrl: "https://x/unsub" })
);
const unsubAnchor = findFirst(
html,
(el) => el.name === "a" && el.attribs.href === "https://x/unsub"
);
expect(unsubAnchor).not.toBeNull();
});
it("omits unsubscribe link when not supplied", { timeout: 5000 }, () => {
const html = renderVNode(EmailFooter({}));
expect(html).not.toContain("Unsubscribe");
});
it("renders a link row with separator", { timeout: 5000 }, () => {
const html = renderVNode(
EmailFooter({
links: [
{ label: "Privacy", href: "https://x/privacy" },
{ label: "Terms", href: "https://x/terms" },
],
})
);
const anchors = findEvery(
html,
(el) =>
el.name === "a" &&
(el.attribs.href === "https://x/privacy" ||
el.attribs.href === "https://x/terms")
);
expect(anchors).toHaveLength(2);
expect(html).toContain(" · ");
});
it("renders only one link without separator", { timeout: 5000 }, () => {
const html = renderVNode(
EmailFooter({
links: [{ label: "Privacy", href: "https://x/privacy" }],
})
);
expect(html).not.toContain(" · ");
});
it("renders children prop", { timeout: 5000 }, () => {
const html = renderVNode(
EmailFooter({
children: "Custom footer line",
} as unknown as Parameters<typeof EmailFooter>[0])
);
expect(html).toContain("Custom footer line");
});
it("merges caller style into the td", { timeout: 5000 }, () => {
const html = renderVNode(
EmailFooter({ style: { "background-color": "#0f172a" } })
);
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.style ?? "").toContain("background-color:#0f172a");
});
});

View File

@@ -0,0 +1,136 @@
/**
* Footer block — copyright, optional address, optional link row,
* optional unsubscribe link. Year is derived from an injectable Clock so
* the module has no implicit Date dependency.
*/
import type { Clock } from "@suaveplan/types";
import { defaultTheme } from "../../theme/theme.js";
import type { EmailTheme } from "../../theme/theme.types.js";
import { h } from "../../vnode/vnode.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type {
EmailFooterLink,
EmailFooterProps,
} from "./email-footer.types.js";
const realClock: Clock = () => Date.now();
function linkRow(
links: ReadonlyArray<EmailFooterLink>,
theme: EmailTheme
): VNode {
const children: Array<VNode | string> = [];
links.forEach((link, i) => {
if (i > 0) children.push(" · ");
children.push(
h(
"a",
{
href: link.href,
target: "_blank",
rel: "noopener noreferrer",
style: {
color: theme.colors.mutedForeground,
"text-decoration": "underline",
},
},
link.label
)
);
});
return h("p", { style: { margin: `0 0 ${theme.spacing.sm} 0` } }, children);
}
export function EmailFooter(props: EmailFooterProps): VNode {
const {
theme = defaultTheme,
clock = realClock,
companyName,
address,
unsubscribeUrl,
links = [],
children,
style,
} = props;
const name = companyName ?? theme.branding.companyName;
const year = new Date(clock()).getUTCFullYear();
const cellChildren: Array<VNode | string> = [];
if (children !== undefined && children !== null && children !== false) {
// `children` may itself be an array of nodes; `h()` accepts the union
// directly, so we re-wrap into a single passthrough VNode and let the
// factory flatten it into our cell list.
const wrapper = h("__pass__", {}, children);
for (const c of wrapper.children) cellChildren.push(c);
}
if (links.length > 0) cellChildren.push(linkRow(links, theme));
cellChildren.push(
h(
"p",
{ style: { margin: `0 0 ${theme.spacing.sm} 0` } },
`© ${year} ${name}`
)
);
if (address !== undefined) {
cellChildren.push(
h("p", { style: { margin: `0 0 ${theme.spacing.sm} 0` } }, address)
);
}
if (unsubscribeUrl !== undefined) {
cellChildren.push(
h(
"p",
{ style: { margin: 0 } },
h(
"a",
{
href: unsubscribeUrl,
target: "_blank",
rel: "noopener noreferrer",
style: {
color: theme.colors.mutedForeground,
"text-decoration": "underline",
"font-size": "12px",
},
},
"Unsubscribe"
)
)
);
}
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: {
width: "100%",
"border-top": `1px solid ${theme.colors.border}`,
"margin-top": theme.spacing.lg,
"padding-top": theme.spacing.lg,
},
},
h(
"tr",
{},
h(
"td",
{
align: "center",
style: {
"font-family": theme.typography.fontFamily,
"font-size": "13px",
"line-height": "1.4",
color: theme.colors.mutedForeground,
...(style ?? {}),
},
},
cellChildren
)
)
);
}

View File

@@ -0,0 +1,19 @@
import type { Clock } from "@suaveplan/types";
import type { EmailTheme } from "../../theme/theme.types.js";
import type { EmailChildren, StyleObject } from "../../vnode/vnode.types.js";
export interface EmailFooterLink {
readonly label: string;
readonly href: string;
}
export interface EmailFooterProps {
readonly theme?: EmailTheme | undefined;
readonly clock?: Clock | undefined;
readonly companyName?: string | undefined;
readonly address?: string | undefined;
readonly unsubscribeUrl?: string | undefined;
readonly links?: ReadonlyArray<EmailFooterLink> | undefined;
readonly children?: EmailChildren | undefined;
readonly style?: StyleObject | undefined;
}

View File

@@ -0,0 +1,5 @@
export { EmailFooter } from "./email-footer.js";
export type {
EmailFooterLink,
EmailFooterProps,
} from "./email-footer.types.js";

View File

@@ -0,0 +1,77 @@
# email-heading
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Renders an `<h1>`…`<h6>` element with a level-keyed font-size and
line-height schedule tuned for inbox-rendering ergonomics. The default
level is `1`. Levels outside the `[1..6]` integer range raise
`EmailTemplatesError(INVALID_HEADING_LEVEL)` — silently falling back to
`h6` (the upstream behaviour) hides typos that would otherwise produce
the wrong semantic structure.
The font-size schedule (`28px`, `24px`, `20px`, `18px`, `16px`, `14px`)
is the conventional email-design ladder. Line-heights tighten as size
grows so larger headings don't pull excessive vertical whitespace into
the body.
## Features
- Per-level font-size + line-height schedule baked in.
- Themed `font-family`, `color`, and bottom margin (`theme.spacing.md`).
- `align` prop (`left` / `center` / `right`) emitted as `text-align`.
- `style` override merged after component defaults.
- Validation: `INVALID_HEADING_LEVEL` for non-integer / out-of-range
values; OTel counter (`validation_failure_total{reason="invalid_heading_level"}`)
bumped on rejection.
## Usage: Basic
```ts
import { EmailHeading } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailHeading({ children: "Welcome aboard" }));
```
## Usage: Advanced
A right-aligned subheading with a brand colour:
```ts
import { EmailHeading } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailHeading({
level: 2,
align: "right",
style: { color: "#22d3ee" },
children: "Subtitle",
}));
```
## API Reference
| Prop | Type | Description |
| ---------- | --------------------------------- | ---------------------------------------------------- |
| `children` | `EmailChildren?` | Heading text. |
| `level` | `HeadingLevel (1..6)?` | Heading tag selector; defaults to `1`. |
| `theme` | `EmailTheme?` | Theme override. |
| `align` | `"left" \| "center" \| "right"` | `text-align` CSS value. |
| `style` | `StyleObject?` | Additional inline styles. |
## Implementation Notes
The font-weight is set to `700` (semibold-bold) regardless of level —
emails benefit from slightly heavier headings than web because client
rendering pipelines often anti-alias more aggressively, washing out
medium-weight type. Components that need a light variant should override
via `style: { "font-weight": 500 }`.
Validation happens at call time (synchronously). The OTel counter bump
gives operators an early signal when a deployment is regressing into
out-of-range heading levels — typically caused by an int-overflow bug in
a template-mapping layer upstream.

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst, textOf } from "../../../tests/setup/html.js";
import { EMAIL_TEMPLATES_CODES } from "../../error-codes/error-codes.js";
import { renderVNode } from "../../vnode/vnode.js";
import { EmailHeading } from "./email-heading.js";
import type { HeadingLevel } from "./email-heading.types.js";
describe("EmailHeading", () => {
it("renders an h1 by default", { timeout: 5000 }, () => {
const html = renderVNode(EmailHeading({ children: "Title" }));
const h1 = findFirst(html, (el) => el.name === "h1");
expect(h1).not.toBeNull();
expect(textOf(h1)).toBe("Title");
});
it("renders the requested heading level", { timeout: 5000 }, () => {
for (const level of [1, 2, 3, 4, 5, 6] as HeadingLevel[]) {
const html = renderVNode(EmailHeading({ level, children: "x" }));
const el = findFirst(html, (e) => e.name === `h${level}`);
expect(el).not.toBeNull();
}
});
it("applies the level-specific font-size", { timeout: 5000 }, () => {
const html = renderVNode(EmailHeading({ level: 2, children: "x" }));
const h2 = findFirst(html, (el) => el.name === "h2");
expect(h2?.attribs.style ?? "").toContain("font-size:24px");
});
it("respects align override", { timeout: 5000 }, () => {
const html = renderVNode(
EmailHeading({ children: "x", align: "center" })
);
const h1 = findFirst(html, (el) => el.name === "h1");
expect(h1?.attribs.style ?? "").toContain("text-align:center");
});
it("merges caller style", { timeout: 5000 }, () => {
const html = renderVNode(
EmailHeading({ children: "x", style: { color: "#ff0000" } })
);
const h1 = findFirst(html, (el) => el.name === "h1");
expect(h1?.attribs.style ?? "").toContain("color:#ff0000");
});
it("rejects invalid level", { timeout: 5000 }, () => {
let caught: unknown;
try {
EmailHeading({
level: 7 as unknown as HeadingLevel,
children: "x",
});
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_HEADING_LEVEL
);
});
it("rejects fractional level", { timeout: 5000 }, () => {
let caught: unknown;
try {
EmailHeading({
level: 2.5 as unknown as HeadingLevel,
children: "x",
});
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_HEADING_LEVEL
);
});
it("rejects level zero", { timeout: 5000 }, () => {
let caught: unknown;
try {
EmailHeading({
level: 0 as unknown as HeadingLevel,
children: "x",
});
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_HEADING_LEVEL
);
});
it("renders empty heading when children omitted", { timeout: 5000 }, () => {
const html = renderVNode(EmailHeading({}));
const h1 = findFirst(html, (el) => el.name === "h1");
expect(h1).not.toBeNull();
});
});

View File

@@ -0,0 +1,74 @@
/**
* Themed heading (h1..h6) for email templates with per-level font size +
* line-height tuned for inbox-rendering ergonomics.
*/
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "../../error-codes/error-codes.js";
import { getEmailTemplatesInstruments } from "../../telemetry/telemetry.js";
import { defaultTheme } from "../../theme/theme.js";
import { h } from "../../vnode/vnode.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type { EmailHeadingProps, HeadingLevel } from "./email-heading.types.js";
const FONT_SIZES: Record<HeadingLevel, string> = {
1: "28px",
2: "24px",
3: "20px",
4: "18px",
5: "16px",
6: "14px",
};
const LINE_HEIGHTS: Record<HeadingLevel, string> = {
1: "1.2",
2: "1.25",
3: "1.3",
4: "1.35",
5: "1.4",
6: "1.4",
};
function isValidLevel(level: number): level is HeadingLevel {
return Number.isInteger(level) && level >= 1 && level <= 6;
}
export function EmailHeading(props: EmailHeadingProps): VNode {
const {
children,
level = 1,
theme = defaultTheme,
align = "left",
style,
} = props;
if (!isValidLevel(level)) {
getEmailTemplatesInstruments().validationFailureCounter.add(1, {
reason: "invalid_heading_level",
});
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_HEADING_LEVEL,
"email_templates.invalid_heading_level"
);
}
return h(
`h${level}`,
{
style: {
margin: `0 0 ${theme.spacing.md} 0`,
padding: 0,
"font-family": theme.typography.headingFontFamily,
"font-size": FONT_SIZES[level],
"line-height": LINE_HEIGHTS[level],
"font-weight": 700,
color: theme.colors.foreground,
"text-align": align,
...(style ?? {}),
},
},
children ?? []
);
}

View File

@@ -0,0 +1,12 @@
import type { EmailTheme } from "../../theme/theme.types.js";
import type { EmailChildren, StyleObject } from "../../vnode/vnode.types.js";
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
export interface EmailHeadingProps {
readonly children?: EmailChildren | undefined;
readonly level?: HeadingLevel | undefined;
readonly theme?: EmailTheme | undefined;
readonly align?: "left" | "center" | "right" | undefined;
readonly style?: StyleObject | undefined;
}

View File

@@ -0,0 +1,5 @@
export { EmailHeading } from "./email-heading.js";
export type {
EmailHeadingProps,
HeadingLevel,
} from "./email-heading.types.js";

View File

@@ -0,0 +1,85 @@
# email-image
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Renders an `<img>` inside a centring `<table>` with the canonical
responsive-email defaults (`display:block`, `max-width:100%`,
`border:0`, `outline:none`). The `alt` attribute is required — missing
alt text breaks accessibility and triggers spam-filter heuristics in
several large providers.
The component is the right choice for hero images, illustrations, and
inline graphics. For brand logos that benefit from MSO-specific
fall-throughs, callers may render an additional `<v:image>` block
wrapped in a `<!--[if mso]>` conditional via the renderer's `rawHtml`
helper.
## Features
- Required `alt` prop (TypeScript will refuse to compile a missing alt).
- Optional pixel `width` and `height` set as both HTML attributes and
CSS values for Outlook-on-desktop compatibility.
- `display:block` removes the inline-baseline gap that produces a thin
border below the image in some clients.
- `max-width:100%` makes images responsive — they scale down inside a
narrower container (mobile) but cap at their natural size on desktop.
- Wrapper `<table>` honours the `align` prop (`center` by default).
## Usage: Basic
```ts
import { EmailImage } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailImage({
src: "https://cdn.example.test/hero.png",
alt: "A welcoming illustration",
width: 600,
height: 300,
}));
```
## Usage: Advanced
A left-aligned logo with rounded corners:
```ts
import { EmailImage } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailImage({
src: "https://cdn.example.test/logo.png",
alt: "Acme",
width: 120,
height: 40,
align: "left",
style: { "border-radius": "4px" },
}));
```
## API Reference
| Prop | Type | Description |
| --------- | --------------------------------- | ---------------------------------------------------- |
| `src` | `string` | Image URL. |
| `alt` | `string` | Accessible alt text — required. |
| `width` | `number?` | Pixel width (HTML + CSS). |
| `height` | `number?` | Pixel height (HTML + CSS). |
| `theme` | `EmailTheme?` | Theme override (used for the bottom margin). |
| `align` | `"left" \| "center" \| "right"` | Cell alignment; defaults to `"center"`. |
| `style` | `StyleObject?` | Additional `<img>` inline styles. |
## Implementation Notes
The `<img>` is wrapped in a single-row `<table>` because Outlook
honours the table's `align` attribute reliably; CSS `text-align` on a
plain `<img>` would behave inconsistently. The wrapping `<td>` carries
the alignment hint while the `<img>` itself stays block-level.
`width` and `height` set both the HTML attributes and the inline CSS
because Outlook-on-Windows preserves the HTML attribute while modern
clients use the CSS; the two values must agree.

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst } from "../../../tests/setup/html.js";
import { renderVNode } from "../../vnode/vnode.js";
import { EmailImage } from "./email-image.js";
describe("EmailImage", () => {
it("renders alt attribute", { timeout: 5000 }, () => {
const html = renderVNode(EmailImage({ src: "a.png", alt: "logo" }));
const img = findFirst(html, (el) => el.name === "img");
expect(img?.attribs.alt).toBe("logo");
});
it("renders src attribute", { timeout: 5000 }, () => {
const html = renderVNode(EmailImage({ src: "a.png", alt: "x" }));
const img = findFirst(html, (el) => el.name === "img");
expect(img?.attribs.src).toBe("a.png");
});
it("emits width and height attributes when provided", {
timeout: 5000,
}, () => {
const html = renderVNode(
EmailImage({ src: "a.png", alt: "x", width: 200, height: 100 })
);
const img = findFirst(html, (el) => el.name === "img");
expect(img?.attribs.width).toBe("200");
expect(img?.attribs.height).toBe("100");
expect(img?.attribs.style ?? "").toContain("height:100px");
});
it("defaults height to auto when omitted", { timeout: 5000 }, () => {
const html = renderVNode(EmailImage({ src: "a.png", alt: "x" }));
const img = findFirst(html, (el) => el.name === "img");
expect(img?.attribs.style ?? "").toContain("height:auto");
});
it("respects align override", { timeout: 5000 }, () => {
const html = renderVNode(
EmailImage({ src: "a.png", alt: "x", align: "left" })
);
const td = findFirst(html, (el) => el.name === "td");
expect(td?.attribs.align).toBe("left");
});
it("merges caller style", { timeout: 5000 }, () => {
const html = renderVNode(
EmailImage({
src: "a.png",
alt: "x",
style: { "border-radius": "8px" },
})
);
const img = findFirst(html, (el) => el.name === "img");
expect(img?.attribs.style ?? "").toContain("border-radius:8px");
});
it("emits img as a self-closing tag", { timeout: 5000 }, () => {
const html = renderVNode(EmailImage({ src: "a.png", alt: "x" }));
expect(html).toContain("<img ");
expect(html).toContain("/>");
});
});

View File

@@ -0,0 +1,56 @@
/**
* Email image with the canonical responsive defaults
* (`display:block`, `max-width:100%`, `border:0`) inside a centring wrapper.
*/
import { defaultTheme } from "../../theme/theme.js";
import { h } from "../../vnode/vnode.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type { EmailImageProps } from "./email-image.types.js";
export function EmailImage(props: EmailImageProps): VNode {
const {
src,
alt,
width,
height,
theme = defaultTheme,
align = "center",
style,
} = props;
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: {
margin: `0 0 ${theme.spacing.md} 0`,
width: "100%",
},
},
h(
"tr",
{},
h(
"td",
{ align },
h("img", {
src,
alt,
width,
height,
style: {
display: "block",
"max-width": "100%",
height: height !== undefined ? `${height}px` : "auto",
border: 0,
outline: "none",
...(style ?? {}),
},
})
)
)
);
}

View File

@@ -0,0 +1,12 @@
import type { EmailTheme } from "../../theme/theme.types.js";
import type { StyleObject } from "../../vnode/vnode.types.js";
export interface EmailImageProps {
readonly src: string;
readonly alt: string;
readonly width?: number | undefined;
readonly height?: number | undefined;
readonly theme?: EmailTheme | undefined;
readonly align?: "left" | "center" | "right" | undefined;
readonly style?: StyleObject | undefined;
}

View File

@@ -0,0 +1,2 @@
export { EmailImage } from "./email-image.js";
export type { EmailImageProps } from "./email-image.types.js";

View File

@@ -0,0 +1,80 @@
# email-layout
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmailLayout` is the root VNode every email template wraps its body in. It
emits a 100%-width `<table>` with a themed muted background, an inner
max-width table centred horizontally, and an optional invisible preview-text
block that surfaces a snippet in the recipient's inbox-list view (Gmail,
Apple Mail, etc.) without appearing in the rendered body.
The table-in-table structure is the canonical email-layout idiom: full-width
clients (Outlook on desktop) honour the inner `<table>`'s pixel `max-width`
where most CSS would be stripped, while modern clients honour both and the
result looks identical.
## Features
- Table-in-table structure with `role="presentation"` so screen readers
treat the markup as layout, not tabular data.
- Optional `preview` prop rendering an invisible snippet (`display:none;
max-height:0; overflow:hidden`) the inbox list samples.
- Themed background, font-family, font-size, line-height, and foreground
colour applied to the outer table; themed border, border-radius, and
max-width applied to the inner table.
- Children rendered into the inner content cell at `padding: theme.spacing.lg`.
## Usage: Basic
```ts
import { EmailLayout } from "@suaveplan/email-templates/components";
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailLayout({
children: h("p", {}, "Hi Jane"),
}));
```
## Usage: Advanced
Pass a custom theme and preview text for inbox-list snippet:
```ts
import { EmailLayout } from "@suaveplan/email-templates/components";
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
import { createTheme } from "@suaveplan/email-templates/theme";
const theme = createTheme({ colors: { muted: "#0f172a", primary: "#22d3ee" } });
renderVNode(EmailLayout({
theme,
preview: "Your invoice for January",
children: h("p", {}, "Body content"),
}));
```
## API Reference
| Prop | Type | Description |
| ---------- | ---------------- | -------------------------------------------------------- |
| `children` | `EmailChildren?` | Body nodes rendered into the inner cell. |
| `theme` | `EmailTheme?` | Theme override; defaults to `defaultTheme`. |
| `preview` | `string?` | Hidden preview text for inbox-list snippet. |
| `style` | `StyleObject?` | Reserved for future use; current renderer ignores. |
## Implementation Notes
The preview block emits a `<div>` (not a `<span>` or `<table>`) because
historically Outlook 2007 mis-handled `display:none` on inline elements
inside tables. The empty-preview branch is exercised by the `omits the
preview block` test to keep the conditional fully covered.
`maxWidth` lives on the **inner** table, not the outer one — the outer
table spans the viewport's full width to paint the muted background
edge-to-edge. The inner table's `max-width: 600px` is the standard
email-design recommendation; Apple's HIG suggests this number to ensure
single-column readability across watch / phone / mail-app contexts.

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findEvery, findFirst, textOf } from "../../../tests/setup/html.js";
import { renderVNode } from "../../vnode/vnode.js";
import { EmailLayout } from "./email-layout.js";
describe("EmailLayout", () => {
it("renders a root table with themed background", { timeout: 5000 }, () => {
const html = renderVNode(EmailLayout({ children: "body" }));
const root = findFirst(html, (el) => el.name === "table");
expect(root).not.toBeNull();
expect(root?.attribs.style ?? "").toContain("background-color:#f8fafc");
});
it("emits preview block when preview prop set", { timeout: 5000 }, () => {
const html = renderVNode(
EmailLayout({ preview: "Glance text", children: "body" })
);
const preview = findFirst(
html,
(el) =>
el.name === "div" &&
(el.attribs.style ?? "").includes("display:none")
);
expect(preview).not.toBeNull();
expect(textOf(preview)).toBe("Glance text");
});
it("omits the preview block when preview is empty", {
timeout: 5000,
}, () => {
const html = renderVNode(EmailLayout({ children: "x" }));
const previews = findEvery(
html,
(el) =>
el.name === "div" &&
(el.attribs.style ?? "").includes("display:none")
);
expect(previews).toHaveLength(0);
});
it("omits preview when explicitly empty string", { timeout: 5000 }, () => {
const html = renderVNode(EmailLayout({ children: "x", preview: "" }));
const previews = findEvery(
html,
(el) =>
el.name === "div" &&
(el.attribs.style ?? "").includes("display:none")
);
expect(previews).toHaveLength(0);
});
it("nests an inner table at theme.maxWidth", { timeout: 5000 }, () => {
const html = renderVNode(EmailLayout({ children: "x" }));
const inner = findFirst(
html,
(el) =>
el.name === "table" &&
(el.attribs.style ?? "").includes("max-width:600px")
);
expect(inner).not.toBeNull();
});
it("renders children inside the inner cell", { timeout: 5000 }, () => {
const html = renderVNode(EmailLayout({ children: "hello world" }));
expect(html).toContain("hello world");
});
it("renders without children", { timeout: 5000 }, () => {
const html = renderVNode(EmailLayout({}));
expect(html).toContain("<table");
});
});

View File

@@ -0,0 +1,88 @@
/**
* Root layout VNode for email templates — a centred, max-width table with
* optional preview-text block, themed background, and a content cell.
*/
import { defaultTheme } from "../../theme/theme.js";
import { h } from "../../vnode/vnode.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type { EmailLayoutProps } from "./email-layout.types.js";
export function EmailLayout(props: EmailLayoutProps): VNode {
const { children, theme = defaultTheme, preview } = props;
const rows: VNode[] = [];
if (preview !== undefined && preview !== "") {
rows.push(
h("tr", {}, [
h(
"td",
{},
h(
"div",
{
style: {
display: "none",
"max-height": 0,
overflow: "hidden",
},
},
preview
)
),
])
);
}
rows.push(
h("tr", {}, [
h(
"td",
{ align: "center", style: { padding: theme.spacing.lg } },
h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: {
width: "100%",
"max-width": theme.maxWidth,
"background-color": theme.colors.background,
"border-radius": theme.borderRadius,
border: `1px solid ${theme.colors.border}`,
},
},
[
h(
"tr",
{},
h(
"td",
{ style: { padding: theme.spacing.lg } },
children ?? []
)
),
]
)
),
])
);
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: {
width: "100%",
"background-color": theme.colors.muted,
"font-family": theme.typography.fontFamily,
"font-size": theme.typography.fontSize,
"line-height": theme.typography.lineHeight,
color: theme.colors.foreground,
},
},
rows
);
}

View File

@@ -0,0 +1,9 @@
import type { EmailTheme } from "../../theme/theme.types.js";
import type { EmailChildren, StyleObject } from "../../vnode/vnode.types.js";
export interface EmailLayoutProps {
readonly children?: EmailChildren | undefined;
readonly theme?: EmailTheme | undefined;
readonly preview?: string | undefined;
readonly style?: StyleObject | undefined;
}

View File

@@ -0,0 +1,2 @@
export { EmailLayout } from "./email-layout.js";
export type { EmailLayoutProps } from "./email-layout.types.js";

View File

@@ -0,0 +1,75 @@
# email-link
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Themed inline `<a>` link with safe defaults (`target="_blank"`,
`rel="noopener noreferrer"`) for body-copy hyperlinks. The default colour
is `theme.colors.primary` and underline is on; both are overridable.
The link component is intentionally minimal — for CTA-style call-to-action
buttons use `EmailButton`, which emits the table-based, MSO-friendly
fallback structure. `EmailLink` is for ordinary in-paragraph references.
## Features
- `target="_blank"` + `rel="noopener noreferrer"` baked in. These two
attributes together close the `window.opener` exfiltration class of
bugs that browsers historically left open for `target="_blank"` links.
- Themed primary colour by default; per-link override via `color` prop.
- Underline on by default (matches link-convention expectations); toggle
via `underline: false` for navigation-style links inside footers.
- `style` override merged after defaults.
## Usage: Basic
```ts
import { EmailLink } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailLink({ href: "https://example.test", children: "click here" }));
```
## Usage: Advanced
A non-underlined branded link inside a footer:
```ts
import { EmailLink } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailLink({
href: "https://example.test/privacy",
underline: false,
color: "#64748b",
style: { "font-size": "12px" },
children: "Privacy Policy",
}));
```
## API Reference
| Prop | Type | Description |
| ----------- | -------------- | ---------------------------------------------------- |
| `href` | `string` | The link target. |
| `children` | `EmailChildren?` | Link label. |
| `theme` | `EmailTheme?` | Theme override. |
| `color` | `string?` | Override the link colour (default: `theme.colors.primary`). |
| `underline` | `boolean?` | Defaults to `true`. |
| `style` | `StyleObject?` | Additional inline styles. |
## Implementation Notes
`href` is escaped via the standard attribute-escape path inside
`renderVNode` — no special handling. The component does NOT validate
that `href` is a well-formed URL; that lives at the caller boundary
(typically a Zod schema upstream).
The `target="_blank"` + `rel="noopener noreferrer"` combination is
non-negotiable: callers cannot opt out. Email-client renderers
historically treat the `_blank` target differently (some open the
default browser, some open an embedded preview), and the `rel` attribute
guards against the `window.opener` reverse-tabnabbing class regardless.

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst } from "../../../tests/setup/html.js";
import { renderVNode } from "../../vnode/vnode.js";
import { EmailLink } from "./email-link.js";
describe("EmailLink", () => {
it("sets safe rel attribute", { timeout: 5000 }, () => {
const html = renderVNode(
EmailLink({ href: "https://x", children: "x" })
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.rel).toBe("noopener noreferrer");
expect(a?.attribs.target).toBe("_blank");
});
it("underlines by default", { timeout: 5000 }, () => {
const html = renderVNode(
EmailLink({ href: "https://x", children: "x" })
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain("text-decoration:underline");
});
it("omits underline when underline=false", { timeout: 5000 }, () => {
const html = renderVNode(
EmailLink({ href: "https://x", children: "x", underline: false })
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain("text-decoration:none");
});
it("uses themed primary colour by default", { timeout: 5000 }, () => {
const html = renderVNode(
EmailLink({ href: "https://x", children: "x" })
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain("color:#2563eb");
});
it("respects an explicit colour override", { timeout: 5000 }, () => {
const html = renderVNode(
EmailLink({ href: "https://x", children: "x", color: "#00ff00" })
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.style ?? "").toContain("color:#00ff00");
});
it("preserves the href attribute verbatim", { timeout: 5000 }, () => {
const html = renderVNode(
EmailLink({
href: "https://example.test/foo?bar=baz",
children: "x",
})
);
const a = findFirst(html, (el) => el.name === "a");
expect(a?.attribs.href).toBe("https://example.test/foo?bar=baz");
});
it("renders without children", { timeout: 5000 }, () => {
const html = renderVNode(EmailLink({ href: "https://x" }));
const a = findFirst(html, (el) => el.name === "a");
expect(a).not.toBeNull();
});
});

View File

@@ -0,0 +1,36 @@
/**
* Themed inline link with safe defaults (target="_blank",
* rel="noopener noreferrer") and configurable colour / underline.
*/
import { defaultTheme } from "../../theme/theme.js";
import { h } from "../../vnode/vnode.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type { EmailLinkProps } from "./email-link.types.js";
export function EmailLink(props: EmailLinkProps): VNode {
const {
href,
children,
theme = defaultTheme,
color,
underline = true,
style,
} = props;
return h(
"a",
{
href,
target: "_blank",
rel: "noopener noreferrer",
style: {
color: color ?? theme.colors.primary,
"text-decoration": underline ? "underline" : "none",
"font-family": theme.typography.fontFamily,
...(style ?? {}),
},
},
children ?? []
);
}

View File

@@ -0,0 +1,11 @@
import type { EmailTheme } from "../../theme/theme.types.js";
import type { EmailChildren, StyleObject } from "../../vnode/vnode.types.js";
export interface EmailLinkProps {
readonly href: string;
readonly children?: EmailChildren | undefined;
readonly theme?: EmailTheme | undefined;
readonly color?: string | undefined;
readonly underline?: boolean | undefined;
readonly style?: StyleObject | undefined;
}

View File

@@ -0,0 +1,2 @@
export { EmailLink } from "./email-link.js";
export type { EmailLinkProps } from "./email-link.types.js";

View File

@@ -0,0 +1,71 @@
# email-text
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Paragraph component for email-body copy. `EmailText` emits a themed `<p>`
with one of three size variants (`body` / `caption` / `lead`), an
alignment hint, and an opt-in muted-colour treatment for de-emphasised
copy like footnotes or expiry notices.
Plain `<p>` elements work in every email client without further coaxing
— this component exists to centralise the font-family / font-size /
line-height / colour cascade so callers don't restate it at every
paragraph site.
## Features
- Three variants — `body` (16/1.5), `caption` (13/1.4), `lead` (18/1.6).
- `align` prop emitted as `text-align`.
- `muted` toggle switches `color` to `theme.colors.mutedForeground`.
- Themed `font-family` and bottom margin (`theme.spacing.md`).
- `style` override merged last.
## Usage: Basic
```ts
import { EmailText } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailText({ children: "Hi Jane, thanks for signing up." }));
```
## Usage: Advanced
A right-aligned muted caption for a one-time-code expiry notice:
```ts
import { EmailText } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailText({
variant: "caption",
align: "right",
muted: true,
children: "This code expires in 5 minutes.",
}));
```
## API Reference
| Prop | Type | Description |
| ---------- | --------------------------------- | ---------------------------------------------------- |
| `children` | `EmailChildren?` | Paragraph content (text or nested links / spans). |
| `theme` | `EmailTheme?` | Theme override. |
| `variant` | `"body" \| "caption" \| "lead"` | Size variant; defaults to `body`. |
| `align` | `"left" \| "center" \| "right"` | `text-align`. |
| `muted` | `boolean?` | Switch to muted foreground colour. |
| `style` | `StyleObject?` | Additional inline styles. |
## Implementation Notes
The variant table is module-local — adding a fourth variant requires
extending both the type union and the table; the TypeScript compiler
refuses to accept a mismatched pair so partial extensions are impossible.
Line heights are intentionally unit-less (e.g. `"1.5"`) so they inherit
correctly into nested elements. Email clients honour unit-less
`line-height` more reliably than `1.5em` or `24px`.

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst, textOf } from "../../../tests/setup/html.js";
import { renderVNode } from "../../vnode/vnode.js";
import { EmailText } from "./email-text.js";
describe("EmailText", () => {
it("renders a paragraph with body variant by default", {
timeout: 5000,
}, () => {
const html = renderVNode(EmailText({ children: "Hello" }));
const p = findFirst(html, (el) => el.name === "p");
expect(p).not.toBeNull();
expect(textOf(p)).toBe("Hello");
expect(p?.attribs.style ?? "").toContain("font-size:16px");
});
it("applies caption variant size", { timeout: 5000 }, () => {
const html = renderVNode(
EmailText({ children: "x", variant: "caption" })
);
const p = findFirst(html, (el) => el.name === "p");
expect(p?.attribs.style ?? "").toContain("13px");
});
it("applies lead variant size", { timeout: 5000 }, () => {
const html = renderVNode(EmailText({ children: "x", variant: "lead" }));
const p = findFirst(html, (el) => el.name === "p");
expect(p?.attribs.style ?? "").toContain("18px");
});
it("applies muted colour when muted=true", { timeout: 5000 }, () => {
const html = renderVNode(EmailText({ children: "x", muted: true }));
const p = findFirst(html, (el) => el.name === "p");
expect(p?.attribs.style ?? "").toContain("color:#64748b");
});
it("respects align override", { timeout: 5000 }, () => {
const html = renderVNode(EmailText({ children: "x", align: "right" }));
const p = findFirst(html, (el) => el.name === "p");
expect(p?.attribs.style ?? "").toContain("text-align:right");
});
it("merges caller style", { timeout: 5000 }, () => {
const html = renderVNode(
EmailText({ children: "x", style: { "font-weight": 700 } })
);
const p = findFirst(html, (el) => el.name === "p");
expect(p?.attribs.style ?? "").toContain("font-weight:700");
});
it("renders empty paragraph when children omitted", {
timeout: 5000,
}, () => {
const html = renderVNode(EmailText({}));
const p = findFirst(html, (el) => el.name === "p");
expect(p).not.toBeNull();
});
});

View File

@@ -0,0 +1,49 @@
/**
* Paragraph text with variant (body / caption / lead) and muted-color
* support. Renders a themed `<p>`.
*/
import { defaultTheme } from "../../theme/theme.js";
import { h } from "../../vnode/vnode.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type { EmailTextProps, TextVariant } from "./email-text.types.js";
const VARIANT_STYLES: Record<
TextVariant,
{ fontSize: string; lineHeight: string }
> = {
body: { fontSize: "16px", lineHeight: "1.5" },
caption: { fontSize: "13px", lineHeight: "1.4" },
lead: { fontSize: "18px", lineHeight: "1.6" },
};
export function EmailText(props: EmailTextProps): VNode {
const {
children,
theme = defaultTheme,
variant = "body",
align = "left",
muted = false,
style,
} = props;
const variantStyle = VARIANT_STYLES[variant];
return h(
"p",
{
style: {
margin: `0 0 ${theme.spacing.md} 0`,
padding: 0,
"font-family": theme.typography.fontFamily,
"font-size": variantStyle.fontSize,
"line-height": variantStyle.lineHeight,
color: muted
? theme.colors.mutedForeground
: theme.colors.foreground,
"text-align": align,
...(style ?? {}),
},
},
children ?? []
);
}

View File

@@ -0,0 +1,13 @@
import type { EmailTheme } from "../../theme/theme.types.js";
import type { EmailChildren, StyleObject } from "../../vnode/vnode.types.js";
export type TextVariant = "body" | "caption" | "lead";
export interface EmailTextProps {
readonly children?: EmailChildren | undefined;
readonly theme?: EmailTheme | undefined;
readonly variant?: TextVariant | undefined;
readonly align?: "left" | "center" | "right" | undefined;
readonly muted?: boolean | undefined;
readonly style?: StyleObject | undefined;
}

View File

@@ -0,0 +1,2 @@
export { EmailText } from "./email-text.js";
export type { EmailTextProps, TextVariant } from "./email-text.types.js";

View File

@@ -0,0 +1,31 @@
export { EmailButton } from "./email-button/email-button.js";
export type {
ButtonSize,
ButtonVariant,
EmailButtonProps,
} from "./email-button/email-button.types.js";
export { EmailContainer } from "./email-container/email-container.js";
export type { EmailContainerProps } from "./email-container/email-container.types.js";
export { EmailDivider } from "./email-divider/email-divider.js";
export type { EmailDividerProps } from "./email-divider/email-divider.types.js";
export { EmailFooter } from "./email-footer/email-footer.js";
export type {
EmailFooterLink,
EmailFooterProps,
} from "./email-footer/email-footer.types.js";
export { EmailHeading } from "./email-heading/email-heading.js";
export type {
EmailHeadingProps,
HeadingLevel,
} from "./email-heading/email-heading.types.js";
export { EmailImage } from "./email-image/email-image.js";
export type { EmailImageProps } from "./email-image/email-image.types.js";
export { EmailLayout } from "./email-layout/email-layout.js";
export type { EmailLayoutProps } from "./email-layout/email-layout.types.js";
export { EmailLink } from "./email-link/email-link.js";
export type { EmailLinkProps } from "./email-link/email-link.types.js";
export { EmailText } from "./email-text/email-text.js";
export type {
EmailTextProps,
TextVariant,
} from "./email-text/email-text.types.js";

View File

@@ -0,0 +1,87 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry for `@suaveplan/email-templates`. Every failure
raised by the package extends `SuaveplanError` with a discriminant `code`
drawn from `EMAIL_TEMPLATES_CODES`, enabling programmatic dispatch by
consumers without coupling to error-message text.
The registry is the contract: every code listed here MUST be thrown by at
least one production code path under `src/`. Removing an entry without
removing its call site is forbidden by §2c of `AGENTS.md`.
## Features
- Six stable codes registered via `defineCodes("EMAIL_TEMPLATES", [...])`.
- `EmailTemplatesError` class with public constructor (so external callers
can synthesise an instance for re-throw / wrapping).
- `EmailTemplatesCode` discriminant union derived from the registry — every
`code` field is statically narrowable.
- Inherits OTel span-event adaptation, redaction, cause-chain traversal,
and i18n message-key resolution from `SuaveplanError`.
## Usage: Basic
```ts
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "@suaveplan/email-templates/errors";
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.UNKNOWN_TEMPLATE,
"email_templates.unknown_template",
);
```
## Usage: Advanced
Wrap an unexpected throwable, preserving its cause for the OTel span-event
adapter to surface in tracing:
```ts
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "@suaveplan/email-templates/errors";
try {
renderToHtml(template, props);
} catch (cause) {
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.RENDER_FAILURE,
"email_templates.render_failure",
{ cause: cause instanceof Error ? cause : new Error(String(cause)) },
);
}
```
## API Reference
| Symbol | Kind | Description |
| ---------------------------- | -------- | --------------------------------------------------- |
| `EMAIL_TEMPLATES_CODES` | const | Frozen record of `EMAIL_TEMPLATES.<NAME>` strings. |
| `EmailTemplatesCode` | type | Discriminant union of every registered code. |
| `EmailTemplatesError` | class | `SuaveplanError<EmailTemplatesCode>` subclass. |
Codes (six): `INVALID_TEMPLATE`, `INVALID_OPTIONS`, `RENDER_FAILURE`,
`DUPLICATE_NAME`, `UNKNOWN_TEMPLATE`, `INVALID_HEADING_LEVEL`.
## Implementation Notes
The `defineCodes` helper from `@suaveplan/error` returns a frozen
`Record<K, "EMAIL_TEMPLATES.K">` and side-steps the literal-string-typing
pitfall by inferring each key as a string-literal type. `EmailTemplatesCode`
is derived via `(typeof EMAIL_TEMPLATES_CODES)[keyof typeof ...]` so adding
or removing a code automatically widens or narrows the union — the
TypeScript compiler will refuse to throw an unknown code.
`name = "EmailTemplatesError"` is set in the constructor (not via static
class-name reflection) so that downcasting via the standard
`err.name === "EmailTemplatesError"` discriminant works under minified
production bundles. `timestamp` is supplied by `SuaveplanError`.

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EMAIL_TEMPLATES_CODES, EmailTemplatesError } from "./error-codes.js";
describe("EMAIL_TEMPLATES_CODES", () => {
it("exposes every code with namespaced value", { timeout: 5000 }, () => {
const expected = [
"INVALID_TEMPLATE",
"INVALID_OPTIONS",
"RENDER_FAILURE",
"DUPLICATE_NAME",
"UNKNOWN_TEMPLATE",
"INVALID_HEADING_LEVEL",
] as const;
for (const key of expected) {
const value =
EMAIL_TEMPLATES_CODES[
key as keyof typeof EMAIL_TEMPLATES_CODES
];
expect(value).toBe(`EMAIL_TEMPLATES.${key}`);
}
});
});
describe("EmailTemplatesError", () => {
it("constructs a typed error with code + name + message key", {
timeout: 5000,
}, () => {
const err = new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE,
"email_templates.invalid_template"
);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("EmailTemplatesError");
expect(err.code).toBe("EMAIL_TEMPLATES.INVALID_TEMPLATE");
expect(typeof err.timestamp).toBe("number");
});
it("EmailTemplatesError preserves cause", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.RENDER_FAILURE,
"email_templates.render_failure",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,35 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/email-templates`.
*
* Every code is thrown by at least one production code path under
* `src/`. Removing an entry without removing its call site is forbidden
* by §2c of `openspec/AGENTS.md`.
*/
export const EMAIL_TEMPLATES_CODES = defineCodes("EMAIL_TEMPLATES", [
"INVALID_TEMPLATE",
"INVALID_OPTIONS",
"RENDER_FAILURE",
"DUPLICATE_NAME",
"UNKNOWN_TEMPLATE",
"INVALID_HEADING_LEVEL",
] as const);
export type EmailTemplatesCode =
(typeof EMAIL_TEMPLATES_CODES)[keyof typeof EMAIL_TEMPLATES_CODES];
/**
* Typed error class for every failure raised by this package.
*/
export class EmailTemplatesError extends SuaveplanError<EmailTemplatesCode> {
public constructor(
code: EmailTemplatesCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "EmailTemplatesError";
}
}

View File

@@ -0,0 +1,5 @@
export {
EMAIL_TEMPLATES_CODES,
type EmailTemplatesCode,
EmailTemplatesError,
} from "./error-codes.js";

View File

@@ -0,0 +1,141 @@
/**
* @module @suaveplan/email-templates
*
* Universal, React-free email template engine: VNode factories,
* nine reusable components, five built-in templates, typed registry,
* fluent builder, theme system, structured errors, OTel instruments.
*/
// Components
export { EmailButton } from "./components/email-button/email-button.js";
export type {
ButtonSize,
ButtonVariant,
EmailButtonProps,
} from "./components/email-button/email-button.types.js";
export { EmailContainer } from "./components/email-container/email-container.js";
export type { EmailContainerProps } from "./components/email-container/email-container.types.js";
export { EmailDivider } from "./components/email-divider/email-divider.js";
export type { EmailDividerProps } from "./components/email-divider/email-divider.types.js";
export { EmailFooter } from "./components/email-footer/email-footer.js";
export type {
EmailFooterLink,
EmailFooterProps,
} from "./components/email-footer/email-footer.types.js";
export { EmailHeading } from "./components/email-heading/email-heading.js";
export type {
EmailHeadingProps,
HeadingLevel,
} from "./components/email-heading/email-heading.types.js";
export { EmailImage } from "./components/email-image/email-image.js";
export type { EmailImageProps } from "./components/email-image/email-image.types.js";
export { EmailLayout } from "./components/email-layout/email-layout.js";
export type { EmailLayoutProps } from "./components/email-layout/email-layout.types.js";
export { EmailLink } from "./components/email-link/email-link.js";
export type { EmailLinkProps } from "./components/email-link/email-link.types.js";
export { EmailText } from "./components/email-text/email-text.js";
export type {
EmailTextProps,
TextVariant,
} from "./components/email-text/email-text.types.js";
export {
EMAIL_TEMPLATES_CODES,
type EmailTemplatesCode,
EmailTemplatesError,
} from "./error-codes/error-codes.js";
export { createEmailMessage } from "./message/message.js";
export type {
CreateEmailMessageOptions,
EmailMessageResult,
EmailRecipient,
} from "./message/message.types.js";
export {
renderToHtml,
resolveSubject,
stripHtml,
wrapInDocument,
} from "./renderer/renderer.js";
export type {
EmailTemplate,
HtmlDocumentOptions,
RenderOptions,
RenderResult,
} from "./renderer/renderer.types.js";
export {
type EmailTemplatesInstruments,
getEmailTemplatesInstruments,
resetEmailTemplatesInstrumentsCacheForTest,
} from "./telemetry/telemetry.js";
export {
createTemplateBuilder,
TemplateBuilder,
} from "./template-builder/template-builder.js";
export type {
EmailComponentFn,
TemplateBuilderApi,
} from "./template-builder/template-builder.types.js";
export {
createRegistry,
TemplateRegistry,
} from "./template-registry/template-registry.js";
export type {
RegistryEntry,
RegistryOptions,
SearchCriteria,
SearchResult,
TemplateMap,
TemplateMetadata,
TemplateRegistryApi,
} from "./template-registry/template-registry.types.js";
// Templates
export { InvoiceEmail, invoiceTemplate } from "./templates/invoice/invoice.js";
export type {
InvoiceLineItem,
InvoiceProps,
} from "./templates/invoice/invoice.types.js";
export {
NotificationEmail,
notificationTemplate,
} from "./templates/notification/notification.js";
export type {
NotificationAction,
NotificationLevel,
NotificationProps,
} from "./templates/notification/notification.types.js";
export {
PasswordResetEmail,
passwordResetTemplate,
} from "./templates/password-reset/password-reset.js";
export type { PasswordResetProps } from "./templates/password-reset/password-reset.types.js";
export {
VerificationEmail,
verificationTemplate,
} from "./templates/verification/verification.js";
export type { VerificationProps } from "./templates/verification/verification.types.js";
export { WelcomeEmail, welcomeTemplate } from "./templates/welcome/welcome.js";
export type { WelcomeProps } from "./templates/welcome/welcome.types.js";
export { createTheme, defaultTheme, mergeThemes } from "./theme/theme.js";
export type {
EmailTheme,
EmailThemeBranding,
EmailThemeColors,
EmailThemeOptions,
EmailThemeSpacing,
EmailThemeTypography,
} from "./theme/theme.types.js";
export {
escapeAttr,
escapeHtml,
h,
rawHtml,
renderNodes,
renderVNode,
serialiseStyle,
} from "./vnode/vnode.js";
export type {
AttrValue,
EmailChildren,
EmailNode,
StyleObject,
VNode,
} from "./vnode/vnode.types.js";

View File

@@ -0,0 +1,6 @@
export { createEmailMessage } from "./message.js";
export type {
CreateEmailMessageOptions,
EmailMessageResult,
EmailRecipient,
} from "./message.types.js";

View File

@@ -0,0 +1,96 @@
# message
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createEmailMessage` glues the renderer to the addressing envelope, producing
an `EmailMessageResult` plain object suitable for handing to an SMTP / SES /
SendGrid / Mailgun client. The shape matches the union of common
email-provider request bodies (`to`, `from`, `cc`, `bcc`, `replyTo`, `subject`,
`html`, `text`), so a single helper covers the contract every transport
implements.
This module is intentionally thin — it owns the recipient-envelope merge
and threads the theme + render options through to `renderToHtml`. All
domain logic lives in the renderer + template; this is the integration
surface consumers wire into their job queues.
## Features
- Spreads the recipient envelope first (allowing the render result to
shadow no addressing fields), then layers `{ subject, html, text }` on
top.
- Accepts `theme` and `renderOptions` independently. The `renderOptions`
type explicitly excludes `theme` to prevent the caller from passing it
twice through two different paths.
- Returns a plain object — every transport accepts plain objects.
## Usage: Basic
```ts
import { createEmailMessage } from "@suaveplan/email-templates/message";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const msg = createEmailMessage(
welcomeTemplate,
{ recipientName: "Jane", ctaUrl: "https://app.example.com" },
{ to: "jane@example.com", from: "hello@suaveplan.com" },
);
await mailer.send(msg);
```
## Usage: Advanced
Layer cc/bcc/replyTo with a custom theme and minified HTML:
```ts
import { createEmailMessage } from "@suaveplan/email-templates/message";
import { createTheme } from "@suaveplan/email-templates/theme";
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({ branding: { companyName: "Acme" } });
const msg = createEmailMessage(
invoiceTemplate,
{
recipientName: "Jane",
invoiceNumber: "001",
invoiceDate: "2026-01-01",
items: [{ description: "Widget", quantity: 2, unitPrice: 10, total: 20 }],
subtotal: 20,
total: 20,
},
{
to: "jane@x",
from: "billing@acme.test",
cc: ["audit@acme.test"],
bcc: "archive@acme.test",
replyTo: "support@acme.test",
},
{ theme, renderOptions: { minify: true, previewText: "Your invoice" } },
);
```
## API Reference
| Symbol | Kind | Description |
| -------------------- | -------- | -------------------------------------------------------- |
| `createEmailMessage` | function | Build an `EmailMessageResult` from a template + envelope. |
Types: `EmailRecipient`, `CreateEmailMessageOptions`, `EmailMessageResult`.
## Implementation Notes
The function performs no validation — it trusts the caller. Address
validation belongs to the transport layer (or to a dedicated validator
upstream); attempting it here would either reject perfectly-legal
addresses (e.g. RFC-5321 quoted local parts) or be too permissive to be
useful. Subject is derived from `template.subject` via `resolveSubject`,
so subject-line interpolation runs through the same code path as the
HTML body.
The result object is a fresh allocation per call — no caching, no
pooling, no mutation of either input. That makes the helper trivially
parallelisable across worker threads.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EmailTemplate } from "../renderer/renderer.types.js";
import { h } from "../vnode/vnode.js";
import { createEmailMessage } from "./message.js";
const tmpl: EmailTemplate<{ name: string }> = {
name: "welcome",
subject: (p) => `Welcome ${p.name}`,
component: (p) => h("p", {}, `Hi ${p.name}`),
};
describe("createEmailMessage", () => {
it("merges recipient envelope with render result", {
timeout: 5000,
}, () => {
const msg = createEmailMessage(
tmpl,
{ name: "Jane" },
{
to: "jane@example.test",
from: "noreply@example.test",
cc: ["a@x", "b@x"],
bcc: "audit@x",
replyTo: "support@x",
}
);
expect(msg.to).toBe("jane@example.test");
expect(msg.from).toBe("noreply@example.test");
expect(msg.cc).toEqual(["a@x", "b@x"]);
expect(msg.bcc).toBe("audit@x");
expect(msg.replyTo).toBe("support@x");
expect(msg.subject).toBe("Welcome Jane");
expect(msg.html).toContain("Hi Jane");
expect(msg.text).toContain("Hi Jane");
});
it("threads renderOptions and theme through to the renderer", {
timeout: 5000,
}, () => {
const msg = createEmailMessage(
tmpl,
{ name: "x" },
{ to: "x@x.test" },
{
theme: undefined,
renderOptions: { minify: true, previewText: "Glance" },
}
);
expect(msg.html).not.toMatch(/\n/);
expect(msg.html).toContain("Glance");
});
});

View File

@@ -0,0 +1,25 @@
/**
* Render a template + merge with a recipient envelope to produce an
* `EmailMessageResult` ready to hand to an SMTP / SES / SendGrid client.
*/
import { renderToHtml } from "../renderer/renderer.js";
import type { EmailTemplate } from "../renderer/renderer.types.js";
import type {
CreateEmailMessageOptions,
EmailMessageResult,
EmailRecipient,
} from "./message.types.js";
export function createEmailMessage<TProps>(
template: EmailTemplate<TProps>,
props: TProps,
recipient: EmailRecipient,
options?: CreateEmailMessageOptions
): EmailMessageResult {
const { html, text, subject } = renderToHtml(template, props, {
...(options?.renderOptions ?? {}),
theme: options?.theme,
});
return { ...recipient, subject, html, text };
}

View File

@@ -0,0 +1,21 @@
import type { RenderOptions } from "../renderer/renderer.types.js";
import type { EmailTheme } from "../theme/theme.types.js";
export interface EmailRecipient {
readonly to: string | ReadonlyArray<string>;
readonly from?: string | undefined;
readonly cc?: string | ReadonlyArray<string> | undefined;
readonly bcc?: string | ReadonlyArray<string> | undefined;
readonly replyTo?: string | undefined;
}
export interface CreateEmailMessageOptions {
readonly theme?: EmailTheme | undefined;
readonly renderOptions?: Omit<RenderOptions, "theme"> | undefined;
}
export interface EmailMessageResult extends EmailRecipient {
readonly subject: string;
readonly html: string;
readonly text: string;
}

View File

@@ -0,0 +1,13 @@
export {
escapeHtml,
renderToHtml,
resolveSubject,
stripHtml,
wrapInDocument,
} from "./renderer.js";
export type {
EmailTemplate,
HtmlDocumentOptions,
RenderOptions,
RenderResult,
} from "./renderer.types.js";

View File

@@ -0,0 +1,107 @@
# renderer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The renderer is the bridge between the `EmailTemplate<TProps>` contract and
a final `{ html, text, subject }` triple ready to hand to an SMTP / SES / SendGrid
client. It calls `template.component(merged_props)` to obtain a `VNode`,
walks the tree through `renderVNode`, wraps the output in a full
`<!doctype html>` document with the head boilerplate every email client
expects, and (by default) derives a plain-text fallback from the HTML body
via tag-stripping.
Every render emits OTel telemetry — `email_templates.render_total` counter
tagged by `template` and `outcome`, plus a duration histogram. Errors raised
by the component are caught and re-thrown as `EmailTemplatesError` with the
`RENDER_FAILURE` code unless they were already a typed error from this
package (those propagate unchanged).
## Features
- `renderToHtml(template, props, options?)` → `{ html, text, subject }`.
- `wrapInDocument(body, options?)` — applies the doctype + head boilerplate.
- `resolveSubject(template, props)` — pure helper for subject derivation.
- `escapeHtml(input)` / `stripHtml(html)` — public utilities reused by
upstream callers.
- Injectable `theme` and `Clock` via render options.
- Optional `minify: true` collapses whitespace between tags.
- Optional `plainText: false` skips text-body generation.
- MSO conditional comment (`<!--[if mso]>...<![endif]-->`) for Outlook
PPI normalisation lives inside `wrapInDocument`.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const { html, text, subject } = renderToHtml(welcomeTemplate, {
recipientName: "Jane",
ctaUrl: "https://app.example.com",
});
```
## Usage: Advanced
Inject a fake clock for deterministic footer year rendering and pre-build a
tenant-specific theme:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { createTheme } from "@suaveplan/email-templates/theme";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({
colors: { primary: "#ff6600" },
branding: { companyName: "Acme" },
});
const result = renderToHtml(
welcomeTemplate,
{ recipientName: "Jane", ctaUrl: "https://acme.test" },
{
theme,
clock: { now: () => 1735689600000 }, // 2025-01-01T00:00:00Z
previewText: "Welcome aboard, Jane!",
minify: true,
},
);
```
## API Reference
| Symbol | Kind | Description |
| ----------------- | -------- | ---------------------------------------------------- |
| `renderToHtml` | function | Render a template to `{ html, text, subject }`. |
| `wrapInDocument` | function | Wrap a body fragment in a full HTML document. |
| `resolveSubject` | function | Resolve `template.subject` (literal or function). |
| `escapeHtml` | function | Escape text for HTML inclusion. |
| `stripHtml` | function | Tag-strip + entity-decode for plain-text fallback. |
Types: `EmailTemplate<TProps>`, `RenderOptions`, `RenderResult`,
`HtmlDocumentOptions`.
## Implementation Notes
The renderer is **not** idempotent across `Date.now()` calls — render
duration is recorded as `Date.now() - start`. Components, however, never
read wall-clock time directly: the renderer threads an injected `Clock`
(falling back to a real-`Date.now()` clock) into the merged props bag, so
template-level determinism is preserved.
`stripHtml` is a regex pass — it does NOT round-trip nested HTML entities
or HTML5 ambiguous markup. Email body content is expected to be the
output of `renderVNode` (well-formed) and is reduced to a coarse
plain-text representation suitable for clients that fall back to the
text part. Production-grade plain-text rendering (line wrapping, link
extraction, list bullets) is the consumer's responsibility.
Errors raised inside `template.component` are caught, the `error` outcome
counter is bumped, and the original cause is preserved on the
`EmailTemplatesError`. Typed errors already raised by this package are
re-thrown without re-wrapping so their original `code` survives the
boundary intact.

View File

@@ -0,0 +1,264 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "../error-codes/error-codes.js";
import { h } from "../vnode/vnode.js";
import {
escapeHtml,
renderToHtml,
resolveSubject,
stripHtml,
wrapInDocument,
} from "./renderer.js";
import type { EmailTemplate } from "./renderer.types.js";
interface SyntheticProps {
readonly name: string;
}
const syntheticTemplate: EmailTemplate<SyntheticProps> = {
name: "synthetic",
subject: (props) => `Hi ${props.name}`,
component: (props) =>
h("div", { id: "root" }, [
h("h1", {}, `Welcome ${props.name}`),
h("p", {}, "Body text"),
]),
};
const literalSubjectTemplate: EmailTemplate<SyntheticProps> = {
name: "literal-subject",
subject: "Fixed Subject",
component: () => h("p", {}, "x"),
};
describe("escapeHtml", () => {
it("escapes ampersand and angle brackets", { timeout: 5000 }, () => {
expect(escapeHtml("a & b < c")).toBe("a &amp; b &lt; c");
});
});
describe("stripHtml", () => {
it("removes tags preserving paragraph breaks", { timeout: 5000 }, () => {
expect(stripHtml("<p>Hi</p><p>There</p>")).toBe("Hi\n\nThere");
});
it("decodes the canonical entities", { timeout: 5000 }, () => {
expect(stripHtml("&amp; &lt; &gt; &quot; &#39; &nbsp;")).toBe(
"& < > \" '"
);
});
it("strips style and script blocks", { timeout: 5000 }, () => {
expect(
stripHtml(
"<style>p{color:red}</style><p>x</p><script>alert(1)</script>"
)
).toBe("x");
});
it("converts <br> to newlines", { timeout: 5000 }, () => {
expect(stripHtml("a<br/>b<br />c")).toBe("a\nb\nc");
});
it("collapses excessive blank lines", { timeout: 5000 }, () => {
expect(stripHtml("<p>a</p><p>b</p><p>c</p>")).toBe("a\n\nb\n\nc");
});
it("converts list and row breaks", { timeout: 5000 }, () => {
expect(stripHtml("<li>a</li><li>b</li>")).toBe("a\nb");
expect(stripHtml("<tr>r1</tr><tr>r2</tr>")).toBe("r1\nr2");
expect(stripHtml("<div>d</div>")).toBe("d");
});
});
describe("resolveSubject", () => {
it("uses function subject when supplied", { timeout: 5000 }, () => {
expect(resolveSubject(syntheticTemplate, { name: "Jane" })).toBe(
"Hi Jane"
);
});
it("uses literal subject when supplied", { timeout: 5000 }, () => {
expect(resolveSubject(literalSubjectTemplate, { name: "x" })).toBe(
"Fixed Subject"
);
});
});
describe("wrapInDocument", () => {
it("wrapInDocument emits doctype", { timeout: 5000 }, () => {
expect(wrapInDocument("<p>x</p>")).toMatch(/^<!DOCTYPE html>/);
});
it("includes the title", { timeout: 5000 }, () => {
expect(wrapInDocument("<p>x</p>", { title: "Subject" })).toContain(
"<title>Subject</title>"
);
});
it("emits the preview-text block when set", { timeout: 5000 }, () => {
expect(wrapInDocument("<p>x</p>", { previewText: "Hi" })).toContain(
"display:none;max-height:0;overflow:hidden"
);
});
it("omits the preview block when preview is empty", {
timeout: 5000,
}, () => {
const out = wrapInDocument("<p>x</p>");
expect(out).not.toContain("display:none;max-height:0");
});
it("respects lang and dir overrides", { timeout: 5000 }, () => {
const out = wrapInDocument("<p>x</p>", { lang: "ar", dir: "rtl" });
expect(out).toContain('lang="ar"');
expect(out).toContain('dir="rtl"');
});
});
describe("renderToHtml", () => {
it("renderToHtml composes welcome template", { timeout: 5000 }, () => {
// synthetic test stands in for "welcome" — see templates/welcome/welcome.test.ts
// for the end-to-end assertion against the real welcomeTemplate.
const out = renderToHtml(syntheticTemplate, { name: "Jane" });
expect(out.html).toMatch(/^<!DOCTYPE html>/);
expect(out.html).toContain("Welcome Jane");
expect(out.subject).toBe("Hi Jane");
expect(out.text).toContain("Welcome Jane");
expect(out.text).toContain("Body text");
});
it("renders preview text into the document", { timeout: 5000 }, () => {
const out = renderToHtml(
syntheticTemplate,
{ name: "Jane" },
{ previewText: "Glance" }
);
expect(out.html).toContain("Glance");
});
it("minifies when minify: true", { timeout: 5000 }, () => {
const out = renderToHtml(
syntheticTemplate,
{ name: "x" },
{ minify: true }
);
expect(out.html).not.toMatch(/\n/);
});
it("omits text body when plainText: false", { timeout: 5000 }, () => {
const out = renderToHtml(
syntheticTemplate,
{ name: "x" },
{ plainText: false }
);
expect(out.text).toBe("");
});
it("wraps non-error throwables as RENDER_FAILURE", {
timeout: 5000,
}, () => {
const broken: EmailTemplate<{ x: string }> = {
name: "broken",
subject: "x",
component: () => {
throw "string-throw";
},
};
let caught: unknown;
try {
renderToHtml(broken, { x: "x" });
} catch (err) {
caught = err;
}
expect(caught).toBeDefined();
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.RENDER_FAILURE
);
});
it("wraps Error throwables as RENDER_FAILURE preserving cause", {
timeout: 5000,
}, () => {
const broken: EmailTemplate<{ x: string }> = {
name: "broken",
subject: "x",
component: () => {
throw new Error("boom");
},
};
let caught: unknown;
try {
renderToHtml(broken, { x: "x" });
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.RENDER_FAILURE
);
expect(((caught as { cause: Error }).cause as Error).message).toBe(
"boom"
);
});
it("re-throws EmailTemplatesError unchanged", { timeout: 5000 }, () => {
const tagged: EmailTemplate<{ x: string }> = {
name: "broken-tagged-real",
subject: "x",
component: () => {
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_OPTIONS,
"key"
);
},
};
let caught: unknown;
try {
renderToHtml(tagged, { x: "x" });
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_OPTIONS
);
});
it("threads injected theme through to the component", {
timeout: 5000,
}, () => {
let captured: { theme?: { colors?: { primary?: string } } } | undefined;
const t: EmailTemplate<{ x: string }> = {
name: "captured",
subject: "x",
component: (props) => {
captured = props;
return h("p", {}, "x");
},
};
renderToHtml(
t,
{ x: "x" },
{ theme: { colors: { primary: "#000" } } as unknown as never }
);
expect(captured?.theme?.colors?.primary).toBe("#000");
});
it("threads injected clock through to the component", {
timeout: 5000,
}, () => {
let captured: { clock?: () => number } | undefined;
const t: EmailTemplate<{ x: string }> = {
name: "clock-capture",
subject: "x",
component: (props) => {
captured = props;
return h("p", {}, "x");
},
};
const fakeClock = () => 1234;
renderToHtml(t, { x: "x" }, { clock: fakeClock });
expect(captured?.clock?.()).toBe(1234);
});
});

View File

@@ -0,0 +1,165 @@
/**
* Renders email-template VNode trees to email-safe HTML strings with
* inline styles, plain-text fallback, and OTel instrumentation.
*/
import type { Clock } from "@suaveplan/types";
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "../error-codes/error-codes.js";
import { getEmailTemplatesInstruments } from "../telemetry/telemetry.js";
import { defaultTheme } from "../theme/theme.js";
import { escapeHtml as escapeAttrText, renderVNode } from "../vnode/vnode.js";
import type {
EmailTemplate,
HtmlDocumentOptions,
RenderOptions,
RenderResult,
} from "./renderer.types.js";
/** Escape text content for HTML — re-exported from vnode for the public API. */
export function escapeHtml(input: string): string {
return escapeAttrText(input);
}
/**
* Strip HTML tags producing plain-text fallback suitable for the `text/plain`
* part of a multipart email. Decodes the canonical entities and preserves
* paragraph / heading / list breaks.
*/
export function stripHtml(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/div>/gi, "\n")
.replace(/<\/tr>/gi, "\n")
.replace(/<\/li>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Resolve a template subject (either a literal or a function of props). */
export function resolveSubject<TProps>(
template: EmailTemplate<TProps>,
props: TProps
): string {
if (typeof template.subject === "function") {
return template.subject(props);
}
return template.subject;
}
/**
* Wrap a body fragment in a full `<!doctype html>` document with the
* `<head>` boilerplate every major email client expects (charset, viewport,
* X-UA-Compatible, MSO conditional comment, `x-apple-disable-message-reformatting`).
*/
export function wrapInDocument(
body: string,
options: HtmlDocumentOptions = {}
): string {
const { title = "", previewText = "", lang = "en", dir = "ltr" } = options;
const previewBlock =
previewText !== ""
? `<div style="display:none;max-height:0;overflow:hidden">${escapeHtml(previewText)}</div>`
: "";
return [
"<!DOCTYPE html>",
`<html lang="${escapeHtml(lang)}" dir="${escapeHtml(dir)}">`,
"<head>",
'<meta charset="utf-8" />',
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
'<meta http-equiv="X-UA-Compatible" content="IE=edge" />',
'<meta name="x-apple-disable-message-reformatting" />',
`<title>${escapeHtml(title)}</title>`,
"<!--[if mso]><noscript><xml>",
"<o:OfficeDocumentSettings>",
"<o:PixelsPerInch>96</o:PixelsPerInch>",
"</o:OfficeDocumentSettings>",
"</xml></noscript><![endif]-->",
"</head>",
`<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">`,
previewBlock,
body,
"</body>",
"</html>",
].join("");
}
function minifyHtml(html: string): string {
return html
.replace(/\n\s*/g, "")
.replace(/\s{2,}/g, " ")
.replace(/>\s+</g, "><");
}
const defaultClock: Clock = () => Date.now();
/**
* Render an `EmailTemplate` to `{ html, text, subject }`. Emits the
* `email_templates.render_total` counter and the `email_templates.render_duration_ms`
* histogram tagged with the template name and outcome.
*/
export function renderToHtml<TProps>(
template: EmailTemplate<TProps>,
props: TProps,
options: RenderOptions = {}
): RenderResult {
const instruments = getEmailTemplatesInstruments();
const start = Date.now();
const theme = options.theme ?? defaultTheme;
const clock = options.clock ?? defaultClock;
const merged = {
...template.defaultProps,
...props,
theme,
clock,
} as TProps & { theme: typeof theme; clock: typeof clock };
try {
const vnode = template.component(merged);
const markup = renderVNode(vnode);
const subject = resolveSubject(template, props);
let html = wrapInDocument(markup, {
title: subject,
previewText: options.previewText,
});
if (options.minify === true) {
html = minifyHtml(html);
}
const text = options.plainText !== false ? stripHtml(markup) : "";
const elapsed = Date.now() - start;
instruments.renderCounter.add(1, {
template: template.name,
outcome: "ok",
});
instruments.renderDurationMs.record(elapsed, {
template: template.name,
});
return { html, text, subject };
} catch (cause) {
instruments.renderCounter.add(1, {
template: template.name,
outcome: "error",
});
if (cause instanceof EmailTemplatesError) {
throw cause;
}
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.RENDER_FAILURE,
"email_templates.render_failure",
{ cause: cause instanceof Error ? cause : new Error(String(cause)) }
);
}
}

View File

@@ -0,0 +1,33 @@
import type { Clock } from "@suaveplan/types";
import type { EmailTheme } from "../theme/theme.types.js";
import type { VNode } from "../vnode/vnode.types.js";
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> | undefined;
}
export interface RenderOptions {
readonly theme?: EmailTheme | undefined;
readonly clock?: Clock | undefined;
readonly plainText?: boolean | undefined;
readonly minify?: boolean | undefined;
readonly previewText?: string | undefined;
}
export interface RenderResult {
readonly html: string;
readonly text: string;
readonly subject: string;
}
export interface HtmlDocumentOptions {
readonly title?: string | undefined;
readonly previewText?: string | undefined;
readonly lang?: string | undefined;
readonly dir?: "ltr" | "rtl" | undefined;
}

View File

@@ -0,0 +1,5 @@
export {
type EmailTemplatesInstruments,
getEmailTemplatesInstruments,
resetEmailTemplatesInstrumentsCacheForTest,
} from "./telemetry.js";

View File

@@ -0,0 +1,81 @@
# telemetry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the OpenTelemetry instrument set published by
`@suaveplan/email-templates`. Every public render or registry mutation
emits a counter (and where useful, a duration histogram) so operators can
build dashboards without instrumenting consumers themselves.
The instrument set is constructed lazily on first access — importing this
module does NOT create a meter or instruments, satisfying the
"side-effects: false" contract in `package.json`. Once constructed, the
set is cached and the same object reference is returned forever (the
metric exporter receives stable instrument identity, which is required
for cumulative-temporality counters).
## Features
- Four instruments: `render_total` (Counter), `render_duration_ms`
(Histogram, unit `ms`), `registry_register_total` (Counter),
`validation_failure_total` (Counter).
- Stable meter name: `@suaveplan/email-templates`.
- `resetEmailTemplatesInstrumentsCacheForTest()` helper to drop the cache
between test runs that swap the global `MeterProvider`.
## Usage: Basic
```ts
import { getEmailTemplatesInstruments } from "@suaveplan/email-templates/otel";
const instruments = getEmailTemplatesInstruments();
instruments.renderCounter.add(1, { template: "welcome", outcome: "ok" });
```
## Usage: Advanced
Wire a real metric exporter in test setup and assert the recorded series
on every render call (the package uses this pattern internally):
```ts
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
const reader = new PeriodicExportingMetricReader({ exporter });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
// ... drive the renderer ...
await reader.forceFlush();
const series = exporter.getMetrics();
// assert series contains email_templates.render_total ...
```
## API Reference
| Symbol | Kind | Description |
| -------------------------------------------- | -------- | -------------------------------------------- |
| `getEmailTemplatesInstruments()` | function | Returns the cached `EmailTemplatesInstruments`. |
| `resetEmailTemplatesInstrumentsCacheForTest()` | function | Drops the module-local cache — test-only. |
| `EmailTemplatesInstruments` | type | The shape of the instrument set. |
## Implementation Notes
The accessor must remain idempotent. Repeated calls return the same
object reference; the test corpus asserts that fact because cumulative
counters lose their backing storage if a meter is re-created mid-flight.
The module never reads `process.env`, `globalThis.window`, or any
DOM-resident global — it is universal (`engines.bun`, `engines.node`,
browser) without conditional code paths.

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getEmailTemplatesInstruments,
resetEmailTemplatesInstrumentsCacheForTest,
} from "./telemetry.js";
describe("getEmailTemplatesInstruments", () => {
it("caches the instrument set", { timeout: 5000 }, () => {
resetEmailTemplatesInstrumentsCacheForTest();
const first = getEmailTemplatesInstruments();
const second = getEmailTemplatesInstruments();
expect(first).toBe(second);
});
it("constructs a meter with stable name", { timeout: 5000 }, () => {
resetEmailTemplatesInstrumentsCacheForTest();
const instruments = getEmailTemplatesInstruments();
expect(instruments.meter).toBeDefined();
expect(instruments.renderCounter).toBeDefined();
expect(instruments.renderDurationMs).toBeDefined();
expect(instruments.registerCounter).toBeDefined();
expect(instruments.validationFailureCounter).toBeDefined();
});
});

View File

@@ -0,0 +1,60 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* Lazily-constructed, shared OTel instruments for email-template rendering.
*
* Names are stable and asserted by `telemetry.test.ts`. The accessor caches
* the instrument set on first call to prevent meter-create churn.
*/
export interface EmailTemplatesInstruments {
readonly meter: Meter;
readonly renderCounter: Counter;
readonly renderDurationMs: Histogram;
readonly registerCounter: Counter;
readonly validationFailureCounter: Counter;
}
let cached: EmailTemplatesInstruments | undefined;
/** Returns the shared instrument set, constructing it on first call. */
export function getEmailTemplatesInstruments(): EmailTemplatesInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/email-templates");
cached = {
meter,
renderCounter: meter.createCounter("email_templates.render_total", {
description: "Number of email templates rendered to HTML.",
}),
renderDurationMs: meter.createHistogram(
"email_templates.render_duration_ms",
{
description: "Duration of renderToHtml() in milliseconds.",
unit: "ms",
}
),
registerCounter: meter.createCounter(
"email_templates.registry_register_total",
{
description:
"Number of templates registered into a TemplateRegistry instance.",
}
),
validationFailureCounter: meter.createCounter(
"email_templates.validation_failure_total",
{
description:
"Number of public-input validation failures by reason.",
}
),
};
return cached;
}
/**
* Reset the cached instrument set. Test-only — never call from production
* code. Used by `telemetry.test.ts` to assert lazy-construction behaviour.
*/
export function resetEmailTemplatesInstrumentsCacheForTest(): void {
cached = undefined;
}

View File

@@ -0,0 +1,8 @@
export {
createTemplateBuilder,
TemplateBuilder,
} from "./template-builder.js";
export type {
EmailComponentFn,
TemplateBuilderApi,
} from "./template-builder.types.js";

View File

@@ -0,0 +1,84 @@
# template-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TemplateBuilder` is a fluent API for assembling an `EmailTemplate<TProps>`
value step-by-step. It exists because constructing the template literal
inline reads poorly when defaults and subject functions are non-trivial,
and because the builder enforces presence of the three required fields
(`name`, `subject`, `component`) at `build()` time with a typed error.
The class itself is a typical builder — each setter mutates the internal
state and returns `this`. `build()` is the one terminal operation. Calls
made after `build()` continue to work (the builder is not consumed), so a
single builder can produce two slightly-different templates by switching
one setter and calling `build()` twice.
## Features
- Fluent setters: `name`, `subject` (literal or function-of-props),
`component`, `defaultProps`.
- Runtime validation in `build()` — missing required fields raise
`EmailTemplatesError(INVALID_TEMPLATE)` with a specific message key.
- `createTemplateBuilder<TProps>()` factory for inference-friendly use.
## Usage: Basic
```ts
import { createTemplateBuilder } from "@suaveplan/email-templates/template-builder";
import { h } from "@suaveplan/email-templates/vnode";
interface Props { name: string; ctaUrl: string }
const myTemplate = createTemplateBuilder<Props>()
.name("custom-welcome")
.subject((p) => `Hi ${p.name}`)
.component((p) => h("a", { href: p.ctaUrl }, "Click me"))
.build();
```
## Usage: Advanced
Reuse a single builder to produce a localised template family by mutating
between `build()` calls:
```ts
import { createTemplateBuilder } from "@suaveplan/email-templates/template-builder";
import { h } from "@suaveplan/email-templates/vnode";
interface Props { name: string }
const b = createTemplateBuilder<Props>()
.component((p) => h("p", {}, `Hi ${p.name}`));
const en = b.name("greet-en").subject("Hello").build();
const fr = b.name("greet-fr").subject("Bonjour").build();
```
`en.subject === "Hello"` and `fr.subject === "Bonjour"` — the builder
returns a fresh `EmailTemplate` object on each `build()` call.
## API Reference
| Symbol | Kind | Description |
| ------------------------ | -------- | ---------------------------------------------------- |
| `TemplateBuilder<T>` | class | The builder itself; instance methods are chainable. |
| `createTemplateBuilder` | function | `<T>() => TemplateBuilder<T>` factory. |
| `TemplateBuilderApi<T>` | type | The interface implemented by `TemplateBuilder<T>`. |
| `EmailComponentFn<T>` | type | `(props: T & { theme?; clock? }) => VNode`. |
## Implementation Notes
The builder never throws inside a setter — invariants are checked
exclusively in `build()`. Empty-string `name` is treated as missing
(consistent with upstream behaviour). The `defaultProps` field is
optional; omitting it leaves it `undefined` on the produced template.
Because `build()` returns a fresh object every time, callers can mutate
the builder and reuse it for closely-related templates — useful for
i18n where the body component is identical and only the subject text
changes by locale.

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EMAIL_TEMPLATES_CODES } from "../error-codes/error-codes.js";
import { h } from "../vnode/vnode.js";
import { createTemplateBuilder, TemplateBuilder } from "./template-builder.js";
interface Props {
readonly a: string;
}
describe("TemplateBuilder", () => {
it("builds a template with literal subject", { timeout: 5000 }, () => {
const tmpl = new TemplateBuilder<Props>()
.name("welcome")
.subject("Hi")
.component(() => h("p", {}, "x"))
.defaultProps({ a: "default" })
.build();
expect(tmpl.name).toBe("welcome");
expect(tmpl.subject).toBe("Hi");
expect(typeof tmpl.component).toBe("function");
expect(tmpl.defaultProps).toEqual({ a: "default" });
});
it("builds a template with function subject", { timeout: 5000 }, () => {
const tmpl = createTemplateBuilder<Props>()
.name("welcome")
.subject((p) => `Hi ${p.a}`)
.component(() => h("p", {}, "x"))
.build();
const s = tmpl.subject;
expect(typeof s).toBe("function");
expect((s as (p: Props) => string)({ a: "Jane" })).toBe("Hi Jane");
});
it("rejects incomplete build", { timeout: 5000 }, () => {
let caught: unknown;
try {
createTemplateBuilder<Props>().name("x").build();
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE
);
});
it("rejects missing name", { timeout: 5000 }, () => {
let caught: unknown;
try {
createTemplateBuilder<Props>()
.subject("x")
.component(() => h("p", {}, "x"))
.build();
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE
);
});
it("rejects missing component", { timeout: 5000 }, () => {
let caught: unknown;
try {
createTemplateBuilder<Props>().name("x").subject("y").build();
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE
);
});
it("rejects empty name", { timeout: 5000 }, () => {
let caught: unknown;
try {
createTemplateBuilder<Props>()
.name("")
.subject("y")
.component(() => h("p", {}, "x"))
.build();
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE
);
});
});

View File

@@ -0,0 +1,76 @@
/**
* Fluent builder for `EmailTemplate` objects with compile-time type
* safety and runtime invariants.
*/
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "../error-codes/error-codes.js";
import type { EmailTemplate } from "../renderer/renderer.types.js";
import type {
EmailComponentFn,
TemplateBuilderApi,
} from "./template-builder.types.js";
export class TemplateBuilder<TProps = unknown>
implements TemplateBuilderApi<TProps>
{
private _name?: string;
private _subject?: string | ((props: TProps) => string);
private _component?: EmailComponentFn<TProps>;
private _defaultProps?: Partial<TProps>;
name(name: string): this {
this._name = name;
return this;
}
subject(subject: string | ((props: TProps) => string)): this {
this._subject = subject;
return this;
}
component(component: EmailComponentFn<TProps>): this {
this._component = component;
return this;
}
defaultProps(defaults: Partial<TProps>): this {
this._defaultProps = defaults;
return this;
}
build(): EmailTemplate<TProps> {
if (this._name === undefined || this._name === "") {
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE,
"email_templates.invalid_template.name_required"
);
}
if (this._subject === undefined) {
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE,
"email_templates.invalid_template.subject_required"
);
}
if (this._component === undefined) {
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE,
"email_templates.invalid_template.component_required"
);
}
return {
name: this._name,
subject: this._subject,
component: this._component,
defaultProps: this._defaultProps,
};
}
}
export function createTemplateBuilder<
TProps = unknown,
>(): TemplateBuilder<TProps> {
return new TemplateBuilder<TProps>();
}

View File

@@ -0,0 +1,18 @@
import type { Clock } from "@suaveplan/types";
import type { EmailTemplate } from "../renderer/renderer.types.js";
import type { EmailTheme } from "../theme/theme.types.js";
import type { VNode } from "../vnode/vnode.types.js";
export type EmailComponentFn<TProps> = (
props: TProps & { theme?: EmailTheme; clock?: Clock }
) => VNode;
export interface TemplateBuilderApi<TProps> {
name(name: string): TemplateBuilderApi<TProps>;
subject(
subject: string | ((props: TProps) => string)
): TemplateBuilderApi<TProps>;
component(component: EmailComponentFn<TProps>): TemplateBuilderApi<TProps>;
defaultProps(defaults: Partial<TProps>): TemplateBuilderApi<TProps>;
build(): EmailTemplate<TProps>;
}

View File

@@ -0,0 +1,13 @@
export {
createRegistry,
TemplateRegistry,
} from "./template-registry.js";
export type {
RegistryEntry,
RegistryOptions,
SearchCriteria,
SearchResult,
TemplateMap,
TemplateMetadata,
TemplateRegistryApi,
} from "./template-registry.types.js";

View File

@@ -0,0 +1,102 @@
# template-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TemplateRegistry` is a type-safe in-memory store for `EmailTemplate`
values keyed by name. It supports per-name lookup, listing, category /
tag / author search, and lifecycle removal — the surface a typical
email-orchestration service needs to dispatch the right template by
name without coupling to the template implementations themselves.
The registry is intentionally non-persistent: callers needing
cross-process state (DB-backed templates, CDN-hosted JSON, etc.) wire
their own store and `register()` into this registry on boot. Templates
remain plain `EmailTemplate` values; the registry never copies or
freezes them.
## Features
- `register(name, template, metadata?)` with duplicate detection and an
OTel counter (`registry_register_total{outcome}`).
- `get(name)` / `has(name)` / `list()` / `size` accessor.
- `search(criteria)` with name (case-insensitive substring), category
(exact), tag (any-intersection), author (exact) filters — combinable.
- `remove(name)` / `clear()` for lifecycle management.
- Optional `defaultTheme` exposed via a getter, useful as a per-registry
fallback for the renderer.
## Usage: Basic
```ts
import { createRegistry } from "@suaveplan/email-templates/template-registry";
import {
invoiceTemplate,
welcomeTemplate,
} from "@suaveplan/email-templates/templates";
const registry = createRegistry();
registry.register("welcome", welcomeTemplate, {
category: "transactional",
tags: ["onboarding"],
});
registry.register("invoice", invoiceTemplate, { category: "billing" });
const t = registry.get("welcome");
```
## Usage: Advanced
Wire a typed map so `get(name)` returns the exact `EmailTemplate<TProps>`
for that key:
```ts
import { createRegistry } from "@suaveplan/email-templates/template-registry";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
import type { WelcomeProps } from "@suaveplan/email-templates/templates";
interface MyMap {
welcome: WelcomeProps;
}
const registry = createRegistry<MyMap>();
registry.register("welcome", welcomeTemplate);
const welcomeT = registry.get("welcome"); // type: EmailTemplate<WelcomeProps> | undefined
```
Search by tag intersection and category:
```ts
registry.search({ tags: ["onboarding"], category: "transactional" });
```
## API Reference
| Symbol | Kind | Description |
| ------------------------- | -------- | ---------------------------------------------------- |
| `TemplateRegistry<TMap>` | class | The registry itself; implements `TemplateRegistryApi`. |
| `createRegistry<TMap>` | function | Convenience factory returning a new registry. |
| `RegistryOptions` | type | Constructor options. |
| `TemplateMetadata` | type | `{ category?, tags?, description?, author? }`. |
| `RegistryEntry` | type | Internal entry shape (template + metadata + when). |
| `SearchCriteria` | type | Search input shape. |
| `SearchResult` | type | Search output shape. |
| `TemplateMap` | type | `Record<string, unknown>` upper bound. |
| `TemplateRegistryApi` | type | The interface implemented by `TemplateRegistry`. |
## Implementation Notes
The registry stores its entries in a single `Map<string, RegistryEntry>`
— ordered insertion is preserved for `list()` and `search()`. Duplicate
registration raises `EmailTemplatesError(DUPLICATE_NAME)` deliberately:
silent overwrite is the most common cause of "wrong template ended up in
the inbox" bugs. Consumers needing replacement should `remove(name)`
first.
`search` uses simple JS `Array.some` / `String.includes` — no fuzzy
matching, no scoring. Tag matching is an OR (any tag in the criterion
must appear in the entry's tags); name matching is substring; category
and author are exact. The criteria are combined with logical AND.

View File

@@ -0,0 +1,186 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EMAIL_TEMPLATES_CODES } from "../error-codes/error-codes.js";
import type { EmailTemplate } from "../renderer/renderer.types.js";
import { createTheme } from "../theme/theme.js";
import { h } from "../vnode/vnode.js";
import { createRegistry, TemplateRegistry } from "./template-registry.js";
const stub = (name: string): EmailTemplate<{ x: string }> => ({
name,
subject: "x",
component: () => h("p", {}, "x"),
});
describe("TemplateRegistry", () => {
it("registers and retrieves a template", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("welcome", stub("welcome"));
expect(r.get("welcome")?.name).toBe("welcome");
expect(r.has("welcome")).toBe(true);
expect(r.size).toBe(1);
});
it("returns undefined for unknown name", { timeout: 5000 }, () => {
const r = createRegistry();
expect(r.get("nope")).toBeUndefined();
expect(r.has("nope")).toBe(false);
});
it("rejects empty name", { timeout: 5000 }, () => {
const r = createRegistry();
let caught: unknown;
try {
r.register("", stub("empty"));
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE
);
});
it("rejects duplicate name", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"));
let caught: unknown;
try {
r.register("a", stub("a"));
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.DUPLICATE_NAME
);
});
it("lists every registered name", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"));
r.register("b", stub("b"));
expect(r.list().sort()).toEqual(["a", "b"]);
});
it("removes a name and reports the deletion", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"));
expect(r.remove("a")).toBe(true);
expect(r.remove("a")).toBe(false);
expect(r.size).toBe(0);
});
it("clears every entry", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"));
r.register("b", stub("b"));
r.clear();
expect(r.size).toBe(0);
});
it("exposes a default theme when supplied", { timeout: 5000 }, () => {
const theme = createTheme({ colors: { primary: "#000" } });
const r = new TemplateRegistry({ defaultTheme: theme });
expect(r.defaultTheme?.colors.primary).toBe("#000");
});
it("defaultTheme is undefined when omitted", { timeout: 5000 }, () => {
const r = new TemplateRegistry();
expect(r.defaultTheme).toBeUndefined();
});
});
describe("TemplateRegistry.search", () => {
it("filters by partial case-insensitive name", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("welcome-en", stub("welcome-en"));
r.register("invoice", stub("invoice"));
expect(r.search({ name: "WELCOME" })).toHaveLength(1);
});
it("filters search by tag", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"), { tags: ["transactional"] });
r.register("b", stub("b"), { tags: ["marketing"] });
const results = r.search({ tags: ["marketing"] });
expect(results).toHaveLength(1);
expect(results[0]?.name).toBe("b");
});
it("filters by category", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"), { category: "tx" });
r.register("b", stub("b"), { category: "mk" });
expect(r.search({ category: "tx" })).toHaveLength(1);
});
it("filters by author", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"), { author: "alice" });
r.register("b", stub("b"), { author: "bob" });
expect(r.search({ author: "alice" })).toHaveLength(1);
});
it("returns empty when no criterion matches", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"));
expect(r.search({ name: "no-match" })).toEqual([]);
});
it("treats empty tags array as no tag filter", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"));
expect(r.search({ tags: [] })).toHaveLength(1);
});
it("combines multiple criteria", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("welcome", stub("welcome"), {
category: "tx",
tags: ["t1"],
author: "alice",
});
expect(
r.search({
name: "welcome",
category: "tx",
tags: ["t1"],
author: "alice",
})
).toHaveLength(1);
expect(
r.search({
name: "welcome",
category: "tx",
tags: ["other"],
author: "alice",
})
).toHaveLength(0);
});
it("returns metadata in the result", { timeout: 5000 }, () => {
const r = createRegistry();
r.register("a", stub("a"), {
description: "d",
author: "x",
tags: ["t"],
category: "c",
});
const [hit] = r.search({ name: "a" });
expect(hit?.metadata?.description).toBe("d");
});
it("excludes entries whose metadata has no tags when tag filter present", {
timeout: 5000,
}, () => {
const r = createRegistry();
r.register("a", stub("a"), { category: "tx" });
expect(r.search({ tags: ["any"] })).toHaveLength(0);
});
it("excludes entries without metadata when tag filter present", {
timeout: 5000,
}, () => {
const r = createRegistry();
r.register("a", stub("a"));
expect(r.search({ tags: ["any"] })).toHaveLength(0);
});
});

View File

@@ -0,0 +1,128 @@
/**
* Type-safe in-memory registry for email templates with category / tag /
* author search, sized via `size`, and lifecycle hooks for OTel.
*/
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "../error-codes/error-codes.js";
import type { EmailTemplate } from "../renderer/renderer.types.js";
import { getEmailTemplatesInstruments } from "../telemetry/telemetry.js";
import type { EmailTheme } from "../theme/theme.types.js";
import type {
RegistryEntry,
RegistryOptions,
SearchCriteria,
SearchResult,
TemplateMap,
TemplateMetadata,
TemplateRegistryApi,
} from "./template-registry.types.js";
export class TemplateRegistry<TMap extends TemplateMap = TemplateMap>
implements TemplateRegistryApi<TMap>
{
private readonly _entries = new Map<string, RegistryEntry>();
private readonly _defaultTheme?: EmailTheme | undefined;
constructor(options: RegistryOptions = {}) {
this._defaultTheme = options.defaultTheme;
}
get size(): number {
return this._entries.size;
}
get defaultTheme(): EmailTheme | undefined {
return this._defaultTheme;
}
register<K extends string, TProps>(
name: K,
template: EmailTemplate<TProps>,
metadata?: TemplateMetadata
): void {
const instruments = getEmailTemplatesInstruments();
if (name === undefined || name === "") {
instruments.registerCounter.add(1, { outcome: "error" });
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_TEMPLATE,
"email_templates.invalid_template.name_required"
);
}
if (this._entries.has(name)) {
instruments.registerCounter.add(1, { outcome: "duplicate" });
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.DUPLICATE_NAME,
"email_templates.duplicate_name"
);
}
this._entries.set(name, {
template: template as EmailTemplate<unknown>,
metadata,
registeredAt: Date.now(),
});
instruments.registerCounter.add(1, { outcome: "ok" });
}
get<K extends keyof TMap>(name: K): EmailTemplate<TMap[K]> | undefined {
const entry = this._entries.get(name as string);
if (entry === undefined) return undefined;
return entry.template as EmailTemplate<TMap[K]>;
}
has(name: string): boolean {
return this._entries.has(name);
}
list(): string[] {
return [...this._entries.keys()];
}
search(criteria: SearchCriteria): SearchResult[] {
const results: SearchResult[] = [];
for (const [name, entry] of this._entries) {
if (!matchesCriteria(name, entry, criteria)) continue;
results.push({ name, metadata: entry.metadata });
}
return results;
}
remove(name: string): boolean {
return this._entries.delete(name);
}
clear(): void {
this._entries.clear();
}
}
function matchesCriteria(
name: string,
entry: RegistryEntry,
criteria: SearchCriteria
): boolean {
if (criteria.name !== undefined) {
const search = criteria.name.toLowerCase();
if (!name.toLowerCase().includes(search)) return false;
}
if (criteria.category !== undefined) {
if (entry.metadata?.category !== criteria.category) return false;
}
if (criteria.tags !== undefined && criteria.tags.length > 0) {
const entryTags = entry.metadata?.tags ?? [];
const hasMatch = criteria.tags.some((tag) => entryTags.includes(tag));
if (!hasMatch) return false;
}
if (criteria.author !== undefined) {
if (entry.metadata?.author !== criteria.author) return false;
}
return true;
}
export function createRegistry<TMap extends TemplateMap = TemplateMap>(
options: RegistryOptions = {}
): TemplateRegistry<TMap> {
return new TemplateRegistry<TMap>(options);
}

View File

@@ -0,0 +1,48 @@
import type { EmailTemplate } from "../renderer/renderer.types.js";
import type { EmailTheme } from "../theme/theme.types.js";
export interface RegistryOptions {
readonly defaultTheme?: EmailTheme | undefined;
}
export interface TemplateMetadata {
readonly category?: string | undefined;
readonly tags?: ReadonlyArray<string> | undefined;
readonly description?: string | undefined;
readonly author?: string | undefined;
}
export interface RegistryEntry<TProps = unknown> {
readonly template: EmailTemplate<TProps>;
readonly metadata?: TemplateMetadata | undefined;
readonly registeredAt: number;
}
export interface SearchCriteria {
readonly name?: string | undefined;
readonly category?: string | undefined;
readonly tags?: ReadonlyArray<string> | undefined;
readonly author?: string | undefined;
}
export interface SearchResult {
readonly name: string;
readonly metadata?: TemplateMetadata | undefined;
}
export type TemplateMap = Record<string, unknown>;
export interface TemplateRegistryApi<TMap extends TemplateMap = TemplateMap> {
register<K extends string, TProps>(
name: K,
template: EmailTemplate<TProps>,
metadata?: TemplateMetadata
): void;
get<K extends keyof TMap>(name: K): EmailTemplate<TMap[K]> | undefined;
has(name: string): boolean;
list(): string[];
search(criteria: SearchCriteria): SearchResult[];
remove(name: string): boolean;
clear(): void;
readonly size: number;
}

View File

@@ -0,0 +1,26 @@
export { InvoiceEmail, invoiceTemplate } from "./invoice/invoice.js";
export type {
InvoiceLineItem,
InvoiceProps,
} from "./invoice/invoice.types.js";
export {
NotificationEmail,
notificationTemplate,
} from "./notification/notification.js";
export type {
NotificationAction,
NotificationLevel,
NotificationProps,
} from "./notification/notification.types.js";
export {
PasswordResetEmail,
passwordResetTemplate,
} from "./password-reset/password-reset.js";
export type { PasswordResetProps } from "./password-reset/password-reset.types.js";
export {
VerificationEmail,
verificationTemplate,
} from "./verification/verification.js";
export type { VerificationProps } from "./verification/verification.types.js";
export { WelcomeEmail, welcomeTemplate } from "./welcome/welcome.js";
export type { WelcomeProps } from "./welcome/welcome.types.js";

View File

@@ -0,0 +1,5 @@
export { InvoiceEmail, invoiceTemplate } from "./invoice.js";
export type {
InvoiceLineItem,
InvoiceProps,
} from "./invoice.types.js";

View File

@@ -0,0 +1,93 @@
# invoice
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `invoiceTemplate` is the receipt / invoice / order-confirmation
email. It renders a heading carrying the invoice number, a metadata line
(date, optional due date), a line-items table with description /
quantity / unit-price / total columns, a subtotal-tax-total summary, and
an optional "Pay Now" CTA when `paymentUrl` is supplied.
The template is intentionally generic — it does not assume a specific
tax-jurisdiction or currency formatting; consumers pass pre-formatted
strings (date, currency symbol) and pre-computed numbers (subtotal,
tax, total). The template focuses on layout, not arithmetic.
## Features
- Line-items table with description, quantity, unit price, total columns.
- Subtotal + optional tax + total summary at right-align.
- Optional `paymentUrl` renders a "Pay Now" CTA.
- Currency symbol is configurable (`currency` defaults to `"$"`).
- Optional `dueDate` appears alongside the invoice date.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(invoiceTemplate, {
recipientName: "Jane",
invoiceNumber: "INV-001",
invoiceDate: "2026-01-15",
items: [
{ description: "Pro plan – Jan 2026", quantity: 1, unitPrice: 29, total: 29 },
],
subtotal: 29,
total: 29,
});
```
## Usage: Advanced
A multi-line invoice with tax, a due date, a payment CTA, and a
non-USD currency:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(invoiceTemplate, {
recipientName: "Jane",
invoiceNumber: "INV-001",
invoiceDate: "2026-01-15",
dueDate: "2026-01-31",
currency: "€",
items: [
{ description: "Pro plan – Jan 2026", quantity: 1, unitPrice: 29, total: 29 },
{ description: "Add-on: extra seat", quantity: 2, unitPrice: 5, total: 10 },
],
subtotal: 39,
tax: 3.9,
total: 42.9,
paymentUrl: "https://billing.example.test/pay?invoice=INV-001",
});
```
## API Reference
| Symbol | Kind | Description |
| ----------------- | -------- | ---------------------------------------------------- |
| `invoiceTemplate` | const | `EmailTemplate<InvoiceProps>`. |
| `InvoiceEmail` | function | Component returning a `VNode`. |
| `InvoiceProps` | type | `{ recipientName, invoiceNumber, invoiceDate, dueDate?, items, subtotal, tax?, total, currency?, paymentUrl?, companyName? }` |
| `InvoiceLineItem` | type | `{ description, quantity, unitPrice, total }`. |
## Implementation Notes
Currency formatting uses `Number.prototype.toFixed(2)` — locale-agnostic
two-decimal-place rendering. Consumers needing locale-aware grouping
separators (e.g. `1,234.56` vs `1 234,56`) should pre-format the strings
upstream; the template intentionally does not depend on `Intl.NumberFormat`
because email-template environments span Node, Bun, and browsers where
the Intl data sets vary.
The line-items table renders a single `<tbody>` per call; the table
header sits in its own `<thead>` so accessibility scanners report the
correct table-with-headers structure even though most email clients
ignore the distinction.

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findEvery, findFirst, textOf } from "../../../tests/setup/html.js";
import { renderToHtml } from "../../renderer/renderer.js";
import { renderVNode } from "../../vnode/vnode.js";
import { InvoiceEmail, invoiceTemplate } from "./invoice.js";
const baseProps = {
recipientName: "Jane",
invoiceNumber: "INV-001",
invoiceDate: "2026-01-01",
items: [
{ description: "Widget", quantity: 2, unitPrice: 10, total: 20 },
{ description: "Gizmo", quantity: 1, unitPrice: 15.5, total: 15.5 },
],
subtotal: 35.5,
total: 35.5,
};
describe("invoiceTemplate", () => {
it("renders line-item rows", { timeout: 5000 }, () => {
const { html } = renderToHtml(invoiceTemplate, baseProps);
const widget = findFirst(
html,
(el) => el.name === "td" && textOf(el).includes("Widget")
);
expect(widget).not.toBeNull();
const gizmo = findFirst(
html,
(el) => el.name === "td" && textOf(el).includes("Gizmo")
);
expect(gizmo).not.toBeNull();
});
it("renders the invoice number in the subject and heading", {
timeout: 5000,
}, () => {
const { html, subject } = renderToHtml(invoiceTemplate, baseProps);
expect(subject).toBe("Invoice #INV-001");
expect(html).toContain("Invoice #INV-001");
});
it("formats currency to two decimal places", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
expect(html).toContain("$10.00");
expect(html).toContain("$15.50");
expect(html).toContain("$35.50");
});
it("uses custom currency symbol", { timeout: 5000 }, () => {
const html = renderVNode(
InvoiceEmail({
...baseProps,
currency: "€",
})
);
expect(html).toContain("€10.00");
expect(html).not.toContain("$10.00");
});
it("omits dueDate when not provided", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
expect(html).toContain("Date: 2026-01-01");
expect(html).not.toContain("Due:");
});
it("renders dueDate when provided", { timeout: 5000 }, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, dueDate: "2026-01-31" })
);
expect(html).toContain("Due: 2026-01-31");
});
it("renders tax row when tax provided", { timeout: 5000 }, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, tax: 3.55, total: 39.05 })
);
expect(html).toContain("Tax: $3.55");
});
it("omits tax row when not provided", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
expect(html).not.toContain("Tax:");
});
it("renders payment button when paymentUrl provided", {
timeout: 5000,
}, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, paymentUrl: "https://x/pay" })
);
const a = findFirst(
html,
(el) => el.name === "a" && el.attribs.href === "https://x/pay"
);
expect(a).not.toBeNull();
});
it("omits payment button when paymentUrl absent", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
expect(html).not.toContain("Pay Now");
});
it("uses explicit companyName", { timeout: 5000 }, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, companyName: "Acme" })
);
expect(html).toContain("from Acme");
});
it("renders the column headers", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
const headers = findEvery(html, (el) => el.name === "th");
const labels = headers.map((h) => textOf(h));
expect(labels).toEqual(["Item", "Qty", "Price", "Total"]);
});
it("renders an empty line-items section when items=[]", {
timeout: 5000,
}, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, items: [], subtotal: 0, total: 0 })
);
const tbody = findFirst(html, (el) => el.name === "tbody");
expect(tbody).not.toBeNull();
});
});

View File

@@ -0,0 +1,229 @@
/**
* Invoice / receipt email. Renders line items in a themed table with
* subtotal / tax / total summary and an optional payment CTA.
*/
import type { Clock } from "@suaveplan/types";
import { EmailButton } from "../../components/email-button/email-button.js";
import { EmailDivider } from "../../components/email-divider/email-divider.js";
import { EmailFooter } from "../../components/email-footer/email-footer.js";
import { EmailHeading } from "../../components/email-heading/email-heading.js";
import { EmailLayout } from "../../components/email-layout/email-layout.js";
import { EmailText } from "../../components/email-text/email-text.js";
import type { EmailTemplate } from "../../renderer/renderer.types.js";
import { defaultTheme } from "../../theme/theme.js";
import type { EmailTheme } from "../../theme/theme.types.js";
import { h } from "../../vnode/vnode.js";
import type { StyleObject, VNode } from "../../vnode/vnode.types.js";
import type { InvoiceLineItem, InvoiceProps } from "./invoice.types.js";
function formatCurrency(amount: number, currency: string): string {
return `${currency}${amount.toFixed(2)}`;
}
function cellStyle(theme: EmailTheme): StyleObject {
return {
padding: `${theme.spacing.sm} ${theme.spacing.xs}`,
"border-bottom": `1px solid ${theme.colors.border}`,
"font-family": theme.typography.fontFamily,
"font-size": "14px",
};
}
function headerStyle(theme: EmailTheme): StyleObject {
return {
padding: `${theme.spacing.sm} ${theme.spacing.xs}`,
"border-bottom": `2px solid ${theme.colors.foreground}`,
"font-family": theme.typography.fontFamily,
"font-size": "13px",
"font-weight": 600,
"text-transform": "uppercase",
color: theme.colors.mutedForeground,
};
}
function lineItemRow(
item: InvoiceLineItem,
currency: string,
theme: EmailTheme
): VNode {
const base = cellStyle(theme);
return h("tr", {}, [
h("td", { style: base }, item.description),
h(
"td",
{ style: { ...base, "text-align": "center" } },
String(item.quantity)
),
h(
"td",
{ style: { ...base, "text-align": "right" } },
formatCurrency(item.unitPrice, currency)
),
h(
"td",
{ style: { ...base, "text-align": "right" } },
formatCurrency(item.total, currency)
),
]);
}
function summaryRow(
label: string,
amount: number,
currency: string,
theme: EmailTheme,
emphasis = false
): VNode {
return h(
"tr",
{},
h(
"td",
{
style: {
"font-family": theme.typography.fontFamily,
"font-size": emphasis ? "18px" : "14px",
"font-weight": emphasis ? 700 : 400,
padding: theme.spacing.xs,
"text-align": "right",
},
},
`${label}: ${formatCurrency(amount, currency)}`
)
);
}
function lineItemsTable(
items: ReadonlyArray<InvoiceLineItem>,
currency: string,
theme: EmailTheme
): VNode {
const hs = headerStyle(theme);
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: { width: "100%", margin: `${theme.spacing.md} 0` },
},
[
h(
"thead",
{},
h("tr", {}, [
h("th", { style: { ...hs, "text-align": "left" } }, "Item"),
h(
"th",
{ style: { ...hs, "text-align": "center" } },
"Qty"
),
h(
"th",
{ style: { ...hs, "text-align": "right" } },
"Price"
),
h(
"th",
{ style: { ...hs, "text-align": "right" } },
"Total"
),
])
),
h(
"tbody",
{},
items.map((item) => lineItemRow(item, currency, theme))
),
]
);
}
function summaryTable(
subtotal: number,
tax: number | undefined,
total: number,
currency: string,
theme: EmailTheme
): VNode {
const rows: VNode[] = [summaryRow("Subtotal", subtotal, currency, theme)];
if (tax !== undefined) {
rows.push(summaryRow("Tax", tax, currency, theme));
}
rows.push(summaryRow("Total", total, currency, theme, true));
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: { width: "100%" },
},
h("tbody", {}, rows)
);
}
export function InvoiceEmail(
props: InvoiceProps & { theme?: EmailTheme; clock?: Clock }
): VNode {
const {
recipientName,
invoiceNumber,
invoiceDate,
dueDate,
items,
subtotal,
tax,
total,
currency = "$",
paymentUrl,
companyName,
theme = defaultTheme,
clock,
} = props;
const name = companyName ?? theme.branding.companyName;
const children: Array<VNode> = [
EmailHeading({
level: 1,
theme,
children: `Invoice #${invoiceNumber}`,
}),
EmailText({
theme,
children: `Hi ${recipientName}, here is your invoice from ${name}.`,
}),
EmailText({
theme,
variant: "caption",
muted: true,
children:
dueDate !== undefined
? `Date: ${invoiceDate} · Due: ${dueDate}`
: `Date: ${invoiceDate}`,
}),
lineItemsTable(items, currency, theme),
summaryTable(subtotal, tax, total, currency, theme),
];
if (paymentUrl !== undefined) {
children.push(
EmailButton({ href: paymentUrl, theme, children: "Pay Now" })
);
}
children.push(
EmailDivider({ theme }),
EmailFooter({ theme, clock, companyName: name })
);
return EmailLayout({ theme, children });
}
export const invoiceTemplate: EmailTemplate<InvoiceProps> = {
name: "invoice",
subject: (props) => `Invoice #${props.invoiceNumber}`,
component: InvoiceEmail,
defaultProps: {
currency: "$",
},
};

View File

@@ -0,0 +1,20 @@
export interface InvoiceLineItem {
readonly description: string;
readonly quantity: number;
readonly unitPrice: number;
readonly total: number;
}
export interface InvoiceProps {
readonly recipientName: string;
readonly invoiceNumber: string;
readonly invoiceDate: string;
readonly dueDate?: string | undefined;
readonly items: ReadonlyArray<InvoiceLineItem>;
readonly subtotal: number;
readonly tax?: number | undefined;
readonly total: number;
readonly currency?: string | undefined;
readonly paymentUrl?: string | undefined;
readonly companyName?: string | undefined;
}

View File

@@ -0,0 +1,9 @@
export {
NotificationEmail,
notificationTemplate,
} from "./notification.js";
export type {
NotificationAction,
NotificationLevel,
NotificationProps,
} from "./notification.types.js";

View File

@@ -0,0 +1,83 @@
# notification
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `notificationTemplate` is the generic system-notification email — a
configurable severity callout suitable for product-health alerts,
audit-trail events, account-status changes, and similar operational
messages. The `level` prop drives both the accent border colour and the
container background, giving recipients an at-a-glance severity cue.
## Features
- Four severity levels — `info`, `success`, `warning`, `error` — each
selecting a distinct accent / background colour pair.
- Optional `timestamp` (rendered as a muted caption above the action).
- Optional `action` button (`{ label, url }`).
- Subject derives from the `title` prop.
- Validation: unknown level raises `EmailTemplatesError(INVALID_OPTIONS)`
and bumps an OTel counter.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { notificationTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(notificationTemplate, {
recipientName: "Jane",
title: "Heads up",
message: "Your subscription renews tomorrow.",
level: "info",
});
```
## Usage: Advanced
A warning notification with a timestamp and a follow-up action:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { notificationTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(notificationTemplate, {
recipientName: "Jane",
title: "Failed login from a new device",
message: "We blocked an attempt from a device we don't recognise. If this was you, you can dismiss this message.",
level: "warning",
timestamp: "2026-05-25 14:32 UTC",
action: {
label: "Review device activity",
url: "https://app.example.test/security/devices",
},
});
```
## API Reference
| Symbol | Kind | Description |
| ----------------------- | -------- | ---------------------------------------------------- |
| `notificationTemplate` | const | `EmailTemplate<NotificationProps>`. |
| `NotificationEmail` | function | Component returning a `VNode`. |
| `NotificationProps` | type | `{ recipientName, title, message, level?, action?, companyName?, timestamp? }` |
| `NotificationLevel` | type | `"info" \| "success" \| "warning" \| "error"`. |
| `NotificationAction` | type | `{ label, url }`. |
## Implementation Notes
The accent colour for `info`/`success`/`warning`/`error` resolves from
the theme's colour palette (`primary` for info; `success`/`warning`/`error`
respectively). The container background, however, is a fixed lighter
tint per level — those values are not in the theme because they're
visually-tuned washes specific to this template's callout pattern.
Consumers wanting a theme-driven wash can override via `EmailContainer`
directly.
The accent-bar effect is achieved with a `<div>` carrying
`border-left: 4px solid <accent>`. The bar lives inside the
`EmailContainer` cell so the container's themed border-radius rounds the
outer edges while the bar stays sharp.

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst } from "../../../tests/setup/html.js";
import { EMAIL_TEMPLATES_CODES } from "../../error-codes/error-codes.js";
import { renderToHtml } from "../../renderer/renderer.js";
import { renderVNode } from "../../vnode/vnode.js";
import { NotificationEmail, notificationTemplate } from "./notification.js";
import type { NotificationLevel } from "./notification.types.js";
describe("notificationTemplate", () => {
it("applies level palette", { timeout: 5000 }, () => {
const { html } = renderToHtml(notificationTemplate, {
recipientName: "Jane",
title: "Heads up",
message: "Something happened.",
level: "warning",
});
expect(html).toContain("#fffbeb");
});
it("supports all four levels", { timeout: 5000 }, () => {
for (const level of [
"info",
"success",
"warning",
"error",
] as NotificationLevel[]) {
const html = renderVNode(
NotificationEmail({
recipientName: "Jane",
title: "x",
message: "y",
level,
})
);
expect(html.length).toBeGreaterThan(0);
}
});
it("defaults level to info when omitted", { timeout: 5000 }, () => {
const html = renderVNode(
NotificationEmail({
recipientName: "Jane",
title: "x",
message: "y",
})
);
expect(html).toContain("#eff6ff");
});
it("rejects invalid level", { timeout: 5000 }, () => {
let caught: unknown;
try {
NotificationEmail({
recipientName: "Jane",
title: "x",
message: "y",
level: "fatal" as unknown as NotificationLevel,
});
} catch (err) {
caught = err;
}
expect((caught as { code: string }).code).toBe(
EMAIL_TEMPLATES_CODES.INVALID_OPTIONS
);
});
it("renders action button when supplied", { timeout: 5000 }, () => {
const html = renderVNode(
NotificationEmail({
recipientName: "Jane",
title: "x",
message: "y",
action: { label: "Review", url: "https://x/review" },
})
);
const a = findFirst(
html,
(el) => el.name === "a" && el.attribs.href === "https://x/review"
);
expect(a).not.toBeNull();
});
it("renders timestamp when supplied", { timeout: 5000 }, () => {
const html = renderVNode(
NotificationEmail({
recipientName: "Jane",
title: "x",
message: "y",
timestamp: "2026-05-25 14:00 UTC",
})
);
expect(html).toContain("2026-05-25 14:00 UTC");
});
it("omits timestamp and action when not supplied", {
timeout: 5000,
}, () => {
const html = renderVNode(
NotificationEmail({
recipientName: "Jane",
title: "Notice",
message: "y",
})
);
expect(html).not.toContain("2026-05-25");
});
it("exposes title-derived subject", { timeout: 5000 }, () => {
const subj = (
notificationTemplate.subject as (p: { title: string }) => string
)({ title: "Heads up" });
expect(subj).toBe("Heads up");
});
it("uses explicit companyName in footer", { timeout: 5000 }, () => {
const html = renderVNode(
NotificationEmail({
recipientName: "Jane",
title: "x",
message: "y",
companyName: "Acme",
})
);
expect(html).toContain("Acme");
});
});

View File

@@ -0,0 +1,133 @@
/**
* Generic notification template with severity-driven palette and an
* optional action button.
*/
import type { Clock } from "@suaveplan/types";
import { EmailButton } from "../../components/email-button/email-button.js";
import { EmailContainer } from "../../components/email-container/email-container.js";
import { EmailDivider } from "../../components/email-divider/email-divider.js";
import { EmailFooter } from "../../components/email-footer/email-footer.js";
import { EmailHeading } from "../../components/email-heading/email-heading.js";
import { EmailLayout } from "../../components/email-layout/email-layout.js";
import { EmailText } from "../../components/email-text/email-text.js";
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "../../error-codes/error-codes.js";
import type { EmailTemplate } from "../../renderer/renderer.types.js";
import { getEmailTemplatesInstruments } from "../../telemetry/telemetry.js";
import { defaultTheme } from "../../theme/theme.js";
import type { EmailTheme } from "../../theme/theme.types.js";
import { h } from "../../vnode/vnode.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type {
NotificationLevel,
NotificationProps,
} from "./notification.types.js";
const LEVELS: ReadonlySet<NotificationLevel> = new Set([
"info",
"success",
"warning",
"error",
]);
function levelAccent(level: NotificationLevel, theme: EmailTheme): string {
switch (level) {
case "info":
return theme.colors.primary;
case "success":
return theme.colors.success;
case "warning":
return theme.colors.warning;
case "error":
return theme.colors.error;
}
}
const LEVEL_BG: Record<NotificationLevel, string> = {
info: "#eff6ff",
success: "#f0fdf4",
warning: "#fffbeb",
error: "#fef2f2",
};
export function NotificationEmail(
props: NotificationProps & { theme?: EmailTheme; clock?: Clock }
): VNode {
const {
recipientName,
title,
message,
level = "info",
action,
companyName,
timestamp,
theme = defaultTheme,
clock,
} = props;
if (!LEVELS.has(level)) {
getEmailTemplatesInstruments().validationFailureCounter.add(1, {
reason: "invalid_notification_level",
});
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.INVALID_OPTIONS,
"email_templates.invalid_options.notification_level"
);
}
const name = companyName ?? theme.branding.companyName;
const accent = levelAccent(level, theme);
const bg = LEVEL_BG[level];
const children: Array<VNode> = [
EmailHeading({ level: 2, theme, children: title }),
EmailText({ theme, children: `Hi ${recipientName},` }),
EmailContainer({
theme,
background: bg,
padding: theme.spacing.md,
children: h(
"div",
{
style: {
"border-left": `4px solid ${accent}`,
"padding-left": theme.spacing.md,
},
},
EmailText({ theme, style: { margin: 0 }, children: message })
),
}),
];
if (timestamp !== undefined) {
children.push(
EmailText({
theme,
variant: "caption",
muted: true,
children: timestamp,
})
);
}
if (action !== undefined) {
children.push(
EmailButton({ href: action.url, theme, children: action.label })
);
}
children.push(
EmailDivider({ theme }),
EmailFooter({ theme, clock, companyName: name })
);
return EmailLayout({ theme, children });
}
export const notificationTemplate: EmailTemplate<NotificationProps> = {
name: "notification",
subject: (props) => props.title,
component: NotificationEmail,
defaultProps: {
level: "info",
},
};

View File

@@ -0,0 +1,16 @@
export type NotificationLevel = "info" | "success" | "warning" | "error";
export interface NotificationAction {
readonly label: string;
readonly url: string;
}
export interface NotificationProps {
readonly recipientName: string;
readonly title: string;
readonly message: string;
readonly level?: NotificationLevel | undefined;
readonly action?: NotificationAction | undefined;
readonly companyName?: string | undefined;
readonly timestamp?: string | undefined;
}

View File

@@ -0,0 +1,5 @@
export {
PasswordResetEmail,
passwordResetTemplate,
} from "./password-reset.js";
export type { PasswordResetProps } from "./password-reset.types.js";

View File

@@ -0,0 +1,83 @@
# password-reset
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `passwordResetTemplate` is the canonical password-reset message. It
explains the request, exposes a reset CTA, communicates the link's
expiry, and includes a "you can safely ignore" caution to reassure
recipients who did not initiate the request.
The template reproduces the reset URL as plain text underneath the CTA
button — a deliberate redundancy that ensures recipients on
text-stripping clients (some webmail security scanners) can still
complete the flow.
## Features
- Themed reset CTA pointing at `resetUrl`.
- Configurable `expiresInMinutes` (defaults to `60`).
- Tinted callout container with the "safely ignore" caution copy.
- Plain-URL fallback below the divider.
- Footer with injectable `Clock`.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { passwordResetTemplate } from "@suaveplan/email-templates/templates";
const { html, text, subject } = renderToHtml(passwordResetTemplate, {
recipientName: "Jane",
resetUrl: "https://app.example.test/reset?token=abc",
});
```
## Usage: Advanced
A 30-minute expiry tied to a tenant-specific brand:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { createTheme } from "@suaveplan/email-templates/theme";
import { passwordResetTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({
colors: { primary: "#22d3ee" },
branding: { companyName: "Acme" },
});
renderToHtml(
passwordResetTemplate,
{
recipientName: "Jane",
resetUrl: "https://acme.test/reset?token=abc",
expiresInMinutes: 30,
companyName: "Acme",
},
{ theme },
);
```
## API Reference
| Symbol | Kind | Description |
| ------------------------ | -------- | ---------------------------------------------------- |
| `passwordResetTemplate` | const | `EmailTemplate<PasswordResetProps>`. |
| `PasswordResetEmail` | function | Component returning a `VNode`. |
| `PasswordResetProps` | type | `{ recipientName, resetUrl, expiresInMinutes?, companyName? }` |
## Implementation Notes
The 60-minute default mirrors the most common security-team-approved
upper bound for password-reset links; tenants needing tighter security
should pass an explicit `expiresInMinutes` and ensure their token-issue
back-end uses the same value.
The "safely ignore" line lives inside the tinted callout to visually
separate it from the CTA, which discourages anxious recipients from
clicking the reset link they did not request — a known social-engineering
attack vector when the caution is text-only.

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst } from "../../../tests/setup/html.js";
import { renderToHtml } from "../../renderer/renderer.js";
import { renderVNode } from "../../vnode/vnode.js";
import { PasswordResetEmail, passwordResetTemplate } from "./password-reset.js";
describe("passwordResetTemplate", () => {
it("renders expiry notice", { timeout: 5000 }, () => {
const { html } = renderToHtml(passwordResetTemplate, {
recipientName: "Jane",
resetUrl: "https://x/reset",
expiresInMinutes: 30,
});
expect(html).toContain("30 minutes");
});
it("defaults expiry to 60 minutes when omitted", { timeout: 5000 }, () => {
const html = renderVNode(
PasswordResetEmail({
recipientName: "Jane",
resetUrl: "https://x/reset",
})
);
expect(html).toContain("60 minutes");
});
it("renders the reset CTA", { timeout: 5000 }, () => {
const html = renderVNode(
PasswordResetEmail({
recipientName: "Jane",
resetUrl: "https://x/reset",
})
);
const a = findFirst(
html,
(el) => el.name === "a" && el.attribs.href === "https://x/reset"
);
expect(a).not.toBeNull();
});
it("reproduces the reset URL as plain-text fallback", {
timeout: 5000,
}, () => {
const html = renderVNode(
PasswordResetEmail({
recipientName: "Jane",
resetUrl: "https://x/reset",
})
);
expect(html).toContain("copy this URL: https://x/reset");
});
it("uses default theme companyName when omitted", { timeout: 5000 }, () => {
const html = renderVNode(
PasswordResetEmail({
recipientName: "Jane",
resetUrl: "https://x/reset",
})
);
expect(html).toContain("your Company account");
});
it("uses explicit companyName", { timeout: 5000 }, () => {
const html = renderVNode(
PasswordResetEmail({
recipientName: "Jane",
resetUrl: "https://x/reset",
companyName: "Acme",
})
);
expect(html).toContain("your Acme account");
});
it("exposes a literal subject", { timeout: 5000 }, () => {
expect(passwordResetTemplate.subject).toBe("Reset Your Password");
});
});

View File

@@ -0,0 +1,73 @@
/**
* Password-reset email. Provides a secure reset link with an expiry
* notice and a copy-paste URL fallback.
*/
import type { Clock } from "@suaveplan/types";
import { EmailButton } from "../../components/email-button/email-button.js";
import { EmailContainer } from "../../components/email-container/email-container.js";
import { EmailDivider } from "../../components/email-divider/email-divider.js";
import { EmailFooter } from "../../components/email-footer/email-footer.js";
import { EmailHeading } from "../../components/email-heading/email-heading.js";
import { EmailLayout } from "../../components/email-layout/email-layout.js";
import { EmailText } from "../../components/email-text/email-text.js";
import type { EmailTemplate } from "../../renderer/renderer.types.js";
import { defaultTheme } from "../../theme/theme.js";
import type { EmailTheme } from "../../theme/theme.types.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type { PasswordResetProps } from "./password-reset.types.js";
export function PasswordResetEmail(
props: PasswordResetProps & { theme?: EmailTheme; clock?: Clock }
): VNode {
const {
recipientName,
resetUrl,
expiresInMinutes = 60,
companyName,
theme = defaultTheme,
clock,
} = props;
const name = companyName ?? theme.branding.companyName;
return EmailLayout({
theme,
children: [
EmailHeading({ level: 1, theme, children: "Reset Your Password" }),
EmailText({
theme,
children: `Hi ${recipientName}, we received a request to reset your password for your ${name} account.`,
}),
EmailButton({ href: resetUrl, theme, children: "Reset Password" }),
EmailContainer({
theme,
background: theme.colors.muted,
padding: theme.spacing.md,
children: EmailText({
theme,
variant: "caption",
muted: true,
style: { margin: 0 },
children: `This link expires in ${expiresInMinutes} minutes. If you didn't request this, you can safely ignore this email.`,
}),
}),
EmailDivider({ theme }),
EmailText({
theme,
variant: "caption",
muted: true,
children: `If the button doesn't work, copy this URL: ${resetUrl}`,
}),
EmailFooter({ theme, clock, companyName: name }),
],
});
}
export const passwordResetTemplate: EmailTemplate<PasswordResetProps> = {
name: "password-reset",
subject: "Reset Your Password",
component: PasswordResetEmail,
defaultProps: {
expiresInMinutes: 60,
},
};

View File

@@ -0,0 +1,6 @@
export interface PasswordResetProps {
readonly recipientName: string;
readonly resetUrl: string;
readonly expiresInMinutes?: number | undefined;
readonly companyName?: string | undefined;
}

View File

@@ -0,0 +1,5 @@
export {
VerificationEmail,
verificationTemplate,
} from "./verification.js";
export type { VerificationProps } from "./verification.types.js";

View File

@@ -0,0 +1,83 @@
# verification
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `verificationTemplate` is the standard email-address verification
flow message. It greets the recipient, optionally displays a one-time
code in a centred tinted container with tracked letter-spacing for
readability, exposes the verify-link CTA, and includes an expiry
notice.
The template handles both code-only and link-only verification flows by
making the `code` field optional. When omitted, the code container is
skipped entirely.
## Features
- Optional one-time code rendering in a tinted container (32 px,
bold, 8 px letter-spacing — the conventional "OTP display" treatment).
- Configurable `expiresInMinutes` (defaults to `30`).
- Themed verify-email CTA pointing at `verificationUrl`.
- Footer with injectable `Clock` for deterministic year.
- "If you didn't create an account" caution paragraph.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { verificationTemplate } from "@suaveplan/email-templates/templates";
const { html, text, subject } = renderToHtml(verificationTemplate, {
recipientName: "Jane",
verificationUrl: "https://app.example.test/verify?token=abc",
code: "ABC123",
});
```
## Usage: Advanced
Link-only verification with a tighter expiry and a tenant-specific
brand:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { createTheme } from "@suaveplan/email-templates/theme";
import { verificationTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({ branding: { companyName: "Acme" } });
renderToHtml(
verificationTemplate,
{
recipientName: "Jane",
verificationUrl: "https://acme.test/verify?token=abc",
expiresInMinutes: 5,
companyName: "Acme",
},
{ theme },
);
```
## API Reference
| Symbol | Kind | Description |
| ----------------------- | -------- | ------------------------------------------------- |
| `verificationTemplate` | const | `EmailTemplate<VerificationProps>`. |
| `VerificationEmail` | function | Component returning a `VNode`. |
| `VerificationProps` | type | `{ recipientName, verificationUrl, code?, expiresInMinutes?, companyName? }` |
## Implementation Notes
The empty-string check on `code` is intentional: an upstream sanitiser
that strips whitespace from a missing-value form field commonly produces
`""` rather than `undefined`. Treating both as "no code" keeps the
template stable against caller-side variance without requiring a Zod
schema at the boundary.
The OTP container's `letter-spacing: 8px` is wide enough that 6-digit
codes render as visually-grouped pairs in most clients, which improves
copy-by-eye accuracy. The font size (32 px) is large enough to remain
legible on a phone-sized email preview without dominating the layout.

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findFirst, textOf } from "../../../tests/setup/html.js";
import { renderToHtml } from "../../renderer/renderer.js";
import { renderVNode } from "../../vnode/vnode.js";
import { VerificationEmail, verificationTemplate } from "./verification.js";
describe("verificationTemplate", () => {
it("renders verification code", { timeout: 5000 }, () => {
const { html } = renderToHtml(verificationTemplate, {
recipientName: "Jane",
verificationUrl: "https://x/verify",
code: "ABC123",
});
const p = findFirst(
html,
(el) => el.name === "p" && textOf(el).includes("ABC123")
);
expect(p).not.toBeNull();
});
it("omits code block when code not supplied", { timeout: 5000 }, () => {
const html = renderVNode(
VerificationEmail({
recipientName: "Jane",
verificationUrl: "https://x/verify",
})
);
expect(html).not.toContain("letter-spacing:8px");
});
it("omits code block for empty string code", { timeout: 5000 }, () => {
const html = renderVNode(
VerificationEmail({
recipientName: "Jane",
verificationUrl: "https://x/verify",
code: "",
})
);
expect(html).not.toContain("letter-spacing:8px");
});
it("renders expiry notice with custom minutes", { timeout: 5000 }, () => {
const html = renderVNode(
VerificationEmail({
recipientName: "Jane",
verificationUrl: "https://x/verify",
expiresInMinutes: 5,
})
);
expect(html).toContain("5 minutes");
});
it("uses default 30 minute expiry when omitted", { timeout: 5000 }, () => {
const html = renderVNode(
VerificationEmail({
recipientName: "Jane",
verificationUrl: "https://x/verify",
})
);
expect(html).toContain("30 minutes");
});
it("uses companyName fallback to theme.branding", { timeout: 5000 }, () => {
const html = renderVNode(
VerificationEmail({
recipientName: "Jane",
verificationUrl: "https://x/verify",
})
);
// default theme companyName = "Company"
expect(html).toContain("Company registration");
});
it("uses explicit companyName", { timeout: 5000 }, () => {
const html = renderVNode(
VerificationEmail({
recipientName: "Jane",
verificationUrl: "https://x/verify",
companyName: "Acme",
})
);
expect(html).toContain("Acme registration");
});
it("exposes a literal subject", { timeout: 5000 }, () => {
expect(verificationTemplate.subject).toBe("Verify Your Email Address");
});
it("renders the verify-email CTA", { timeout: 5000 }, () => {
const html = renderVNode(
VerificationEmail({
recipientName: "Jane",
verificationUrl: "https://x/verify",
})
);
const a = findFirst(
html,
(el) => el.name === "a" && el.attribs.href === "https://x/verify"
);
expect(a).not.toBeNull();
});
});

View File

@@ -0,0 +1,91 @@
/**
* Email-address verification template. Provides a verify link, an
* optional one-time code displayed in a tinted container, and an
* expiry notice.
*/
import type { Clock } from "@suaveplan/types";
import { EmailButton } from "../../components/email-button/email-button.js";
import { EmailContainer } from "../../components/email-container/email-container.js";
import { EmailDivider } from "../../components/email-divider/email-divider.js";
import { EmailFooter } from "../../components/email-footer/email-footer.js";
import { EmailHeading } from "../../components/email-heading/email-heading.js";
import { EmailLayout } from "../../components/email-layout/email-layout.js";
import { EmailText } from "../../components/email-text/email-text.js";
import type { EmailTemplate } from "../../renderer/renderer.types.js";
import { defaultTheme } from "../../theme/theme.js";
import type { EmailTheme } from "../../theme/theme.types.js";
import type { VNode } from "../../vnode/vnode.types.js";
import type { VerificationProps } from "./verification.types.js";
export function VerificationEmail(
props: VerificationProps & { theme?: EmailTheme; clock?: Clock }
): VNode {
const {
recipientName,
verificationUrl,
code,
expiresInMinutes = 30,
companyName,
theme = defaultTheme,
clock,
} = props;
const name = companyName ?? theme.branding.companyName;
const children: Array<VNode> = [
EmailHeading({ level: 1, theme, children: "Verify Your Email" }),
EmailText({
theme,
children: `Hi ${recipientName}, please verify your email address to complete your ${name} registration.`,
}),
];
if (code !== undefined && code !== "") {
children.push(
EmailContainer({
theme,
background: theme.colors.muted,
padding: theme.spacing.lg,
align: "center",
children: EmailText({
theme,
align: "center",
style: {
"font-size": "32px",
"font-weight": 700,
"letter-spacing": "8px",
margin: 0,
},
children: code,
}),
})
);
}
children.push(
EmailButton({ href: verificationUrl, theme, children: "Verify Email" }),
EmailText({
theme,
variant: "caption",
muted: true,
children: `This link expires in ${expiresInMinutes} minutes.`,
}),
EmailDivider({ theme }),
EmailText({
theme,
variant: "caption",
muted: true,
children: `If you didn't create an account with ${name}, please ignore this email.`,
}),
EmailFooter({ theme, clock, companyName: name })
);
return EmailLayout({ theme, children });
}
export const verificationTemplate: EmailTemplate<VerificationProps> = {
name: "verification",
subject: "Verify Your Email Address",
component: VerificationEmail,
defaultProps: {
expiresInMinutes: 30,
},
};

View File

@@ -0,0 +1,7 @@
export interface VerificationProps {
readonly recipientName: string;
readonly verificationUrl: string;
readonly code?: string | undefined;
readonly expiresInMinutes?: number | undefined;
readonly companyName?: string | undefined;
}

View File

@@ -0,0 +1,2 @@
export { WelcomeEmail, welcomeTemplate } from "./welcome.js";
export type { WelcomeProps } from "./welcome.types.js";

View File

@@ -0,0 +1,84 @@
# welcome
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `welcomeTemplate` is the canonical post-signup onboarding email. It
greets the recipient by name, summarises why they received the email,
exposes the primary CTA, and reproduces the CTA URL as plain text
underneath in case the button does not survive a link-rewriting proxy.
The template is intentionally minimal — it covers the most common
post-signup flow. Consumers needing additional structure (feature
highlights, social-proof imagery, multi-step onboarding) should compose
their own template using the same components.
## Features
- Themed heading + body copy + primary CTA + plain-URL fallback +
divider + footer.
- Configurable `ctaLabel` (defaults to `"Get Started"`).
- Configurable `companyName` (defaults to `theme.branding.companyName`).
- Subject function: `Welcome to ${companyName}!`.
- Optional preview text via the `previewText` prop.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const { html, text, subject } = renderToHtml(welcomeTemplate, {
recipientName: "Jane",
ctaUrl: "https://app.example.test/get-started",
});
```
## Usage: Advanced
Brand-customised with a preview text and a non-default CTA label:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { createTheme } from "@suaveplan/email-templates/theme";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({
colors: { primary: "#22d3ee" },
branding: { companyName: "Acme Corp" },
});
const result = renderToHtml(
welcomeTemplate,
{
recipientName: "Jane",
ctaUrl: "https://acme.test/welcome",
ctaLabel: "Continue",
previewText: "You're in! Let's get you set up.",
},
{ theme },
);
```
## API Reference
| Symbol | Kind | Description |
| ------------------ | -------- | ---------------------------------------------------- |
| `welcomeTemplate` | const | `EmailTemplate<WelcomeProps>`. |
| `WelcomeEmail` | function | Direct component access (returns a `VNode`). |
| `WelcomeProps` | type | `{ recipientName, ctaUrl, ctaLabel?, companyName?, previewText? }` |
## Implementation Notes
The plain-URL fallback paragraph is rendered as a muted caption — small
type, low contrast — so it doesn't compete with the CTA visually. The
fallback line uses the same `ctaUrl` value as the button, so any later
URL change touches one source of truth.
The footer's clock is intentionally not threaded through this template
— consumers wanting a deterministic copyright year supply a `clock` to
`renderToHtml`, which passes it to the component as part of the merged
props bag.

Some files were not shown because too many files have changed in this diff Show More