Initial Commit
This commit is contained in:
118
packages/communication/emails/email-templates/README.md
Normal file
118
packages/communication/emails/email-templates/README.md
Normal 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.
|
||||
|
||||
121
packages/communication/emails/email-templates/package.json
Normal file
121
packages/communication/emails/email-templates/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { EmailButton } from "./email-button.js";
|
||||
export type {
|
||||
ButtonSize,
|
||||
ButtonVariant,
|
||||
EmailButtonProps,
|
||||
} from "./email-button.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? []
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EmailContainer } from "./email-container.js";
|
||||
export type { EmailContainerProps } from "./email-container.types.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
},
|
||||
" "
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EmailDivider } from "./email-divider.js";
|
||||
export type { EmailDividerProps } from "./email-divider.types.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { EmailFooter } from "./email-footer.js";
|
||||
export type {
|
||||
EmailFooterLink,
|
||||
EmailFooterProps,
|
||||
} from "./email-footer.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? []
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { EmailHeading } from "./email-heading.js";
|
||||
export type {
|
||||
EmailHeadingProps,
|
||||
HeadingLevel,
|
||||
} from "./email-heading.types.js";
|
||||
@@ -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.
|
||||
@@ -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("/>");
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EmailImage } from "./email-image.js";
|
||||
export type { EmailImageProps } from "./email-image.types.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EmailLayout } from "./email-layout.js";
|
||||
export type { EmailLayoutProps } from "./email-layout.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? []
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EmailLink } from "./email-link.js";
|
||||
export type { EmailLinkProps } from "./email-link.types.js";
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? []
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EmailText } from "./email-text.js";
|
||||
export type { EmailTextProps, TextVariant } from "./email-text.types.js";
|
||||
@@ -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";
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
EMAIL_TEMPLATES_CODES,
|
||||
type EmailTemplatesCode,
|
||||
EmailTemplatesError,
|
||||
} from "./error-codes.js";
|
||||
141
packages/communication/emails/email-templates/src/index.ts
Normal file
141
packages/communication/emails/email-templates/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { createEmailMessage } from "./message.js";
|
||||
export type {
|
||||
CreateEmailMessageOptions,
|
||||
EmailMessageResult,
|
||||
EmailRecipient,
|
||||
} from "./message.types.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
escapeHtml,
|
||||
renderToHtml,
|
||||
resolveSubject,
|
||||
stripHtml,
|
||||
wrapInDocument,
|
||||
} from "./renderer.js";
|
||||
export type {
|
||||
EmailTemplate,
|
||||
HtmlDocumentOptions,
|
||||
RenderOptions,
|
||||
RenderResult,
|
||||
} from "./renderer.types.js";
|
||||
@@ -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.
|
||||
@@ -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 & b < 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("& < > " ' ")).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);
|
||||
});
|
||||
});
|
||||
@@ -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(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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)) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type EmailTemplatesInstruments,
|
||||
getEmailTemplatesInstruments,
|
||||
resetEmailTemplatesInstrumentsCacheForTest,
|
||||
} from "./telemetry.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createTemplateBuilder,
|
||||
TemplateBuilder,
|
||||
} from "./template-builder.js";
|
||||
export type {
|
||||
EmailComponentFn,
|
||||
TemplateBuilderApi,
|
||||
} from "./template-builder.types.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { InvoiceEmail, invoiceTemplate } from "./invoice.js";
|
||||
export type {
|
||||
InvoiceLineItem,
|
||||
InvoiceProps,
|
||||
} from "./invoice.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: "$",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
NotificationEmail,
|
||||
notificationTemplate,
|
||||
} from "./notification.js";
|
||||
export type {
|
||||
NotificationAction,
|
||||
NotificationLevel,
|
||||
NotificationProps,
|
||||
} from "./notification.types.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
PasswordResetEmail,
|
||||
passwordResetTemplate,
|
||||
} from "./password-reset.js";
|
||||
export type { PasswordResetProps } from "./password-reset.types.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface PasswordResetProps {
|
||||
readonly recipientName: string;
|
||||
readonly resetUrl: string;
|
||||
readonly expiresInMinutes?: number | undefined;
|
||||
readonly companyName?: string | undefined;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
VerificationEmail,
|
||||
verificationTemplate,
|
||||
} from "./verification.js";
|
||||
export type { VerificationProps } from "./verification.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { WelcomeEmail, welcomeTemplate } from "./welcome.js";
|
||||
export type { WelcomeProps } from "./welcome.types.js";
|
||||
@@ -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
Reference in New Issue
Block a user