Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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.
|
||||
@@ -0,0 +1,89 @@
|
||||
# theme
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the theme tokens consumed by every email component — colours,
|
||||
typography, spacing, branding, border-radius, and max-width. Themes are
|
||||
immutable plain objects: `createTheme` returns a fresh object every call
|
||||
and never mutates the shipped `defaultTheme`, so the module is safe to
|
||||
import in cross-tenant contexts where two consumers compose distinct
|
||||
themes side-by-side.
|
||||
|
||||
The default palette is `#2563eb` primary (Tailwind blue-600), a slate
|
||||
secondary, and a 600 px max-width — sane email-client defaults that
|
||||
render correctly in Gmail, Apple Mail, Outlook 2016+, and the major web
|
||||
clients without further customisation.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultTheme`: frozen default `EmailTheme` instance.
|
||||
- `createTheme(options?)`: deep-merges `options` into `defaultTheme`,
|
||||
returning a new object.
|
||||
- `mergeThemes(base, override)`: shallow-merges each section into `base`.
|
||||
- Every section is independently overridable — partial colour overrides
|
||||
leave other colours intact.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createTheme } from "@suaveplan/email-templates/theme";
|
||||
|
||||
const theme = createTheme({
|
||||
colors: { primary: "#ff6600" },
|
||||
branding: { companyName: "Acme Corp" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose a per-locale theme by layering region-specific tokens onto a base:
|
||||
|
||||
```ts
|
||||
import { createTheme, mergeThemes } from "@suaveplan/email-templates/theme";
|
||||
|
||||
const base = createTheme({
|
||||
branding: { companyName: "Acme" },
|
||||
typography: {
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
},
|
||||
});
|
||||
|
||||
const jaTheme = mergeThemes(base, {
|
||||
typography: { fontFamily: "'Noto Sans JP', sans-serif" },
|
||||
});
|
||||
```
|
||||
|
||||
`mergeThemes` differs from `createTheme` in that it merges into the
|
||||
explicitly-supplied `base` (which may itself differ from `defaultTheme`)
|
||||
— useful when a multi-tenant pipeline pre-builds a per-tenant base and
|
||||
then layers a per-locale override on top.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| -------------- | -------- | ---------------------------------------------------------- |
|
||||
| `defaultTheme` | const | The shipped default `EmailTheme`. |
|
||||
| `createTheme` | function | `(options?) => EmailTheme` — overrides applied to default. |
|
||||
| `mergeThemes` | function | `(base, override) => EmailTheme`. |
|
||||
|
||||
Types: `EmailTheme`, `EmailThemeColors`, `EmailThemeTypography`,
|
||||
`EmailThemeSpacing`, `EmailThemeBranding`, `EmailThemeOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Token defaults are declared as module-local `const … as const`
|
||||
expressions so they cannot be re-bound; the exported `defaultTheme` is a
|
||||
spread of those tokens. The exported value is intentionally **not**
|
||||
`Object.freeze`'d — TypeScript's `readonly` already prevents accidental
|
||||
re-assignment at the type level, and freezing would interact badly with
|
||||
serialise / hydrate flows in downstream tests.
|
||||
|
||||
Spread-merging means deep nested fields cannot be partially overridden
|
||||
within the same `createTheme` call (e.g. you cannot override one branding
|
||||
field without losing the others) — each section is treated as a single
|
||||
unit. That matches upstream behaviour exactly; consumers who need a
|
||||
deeper merge can compose two `createTheme` calls with `mergeThemes`.
|
||||
@@ -0,0 +1,101 @@
|
||||
# vnode
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the universal VNode primitive — a `{ tag, attrs, children }`
|
||||
triple plus a pure-string HTML renderer — that lets `@suaveplan/email-templates`
|
||||
deliver the full upstream component surface without any React or DOM runtime
|
||||
dependency. Components emit VNode trees; the renderer is the single point at
|
||||
which children are HTML-escaped and attributes are serialised, removing the
|
||||
class of XSS bugs introduced when escaping is sprinkled per-component.
|
||||
|
||||
The module is the structural twin of `react-dom/server.renderToStaticMarkup`
|
||||
restricted to the HTML subset email clients accept: every element is one of
|
||||
the standard tags, every attribute is a primitive or a single `style` object,
|
||||
and the renderer always closes opened tags (no fragment / portal / boundary
|
||||
machinery).
|
||||
|
||||
## Features
|
||||
|
||||
- `h(tag, attrs, children, options?)` hyperscript factory with automatic
|
||||
self-closing detection for void HTML elements (`img`, `br`, `hr`, `meta`,
|
||||
`link`, `input`, `area`, `base`, `col`, `embed`, `source`, `track`, `wbr`).
|
||||
- `renderVNode(node)` walks the tree producing email-safe HTML, escaping text
|
||||
with the five-metacharacter HTML rule (`&<>"'`) and attribute values with
|
||||
the four-character attribute rule (`&"<>`).
|
||||
- `serialiseStyle(obj)` converts `{ borderRadius: '6px' }` to
|
||||
`border-radius:6px`, kebab-casing camelCase keys and omitting `undefined`.
|
||||
- `rawHtml(html)` escape hatch for the MSO conditional comment block emitted
|
||||
by `wrapInDocument`. Children are echoed verbatim — never pass user input.
|
||||
- `renderNodes(list)` concatenates a node list (used by the renderer).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
const html = renderVNode(
|
||||
h("p", { style: { color: "red" } }, "Hello, world"),
|
||||
);
|
||||
// '<p style="color:red">Hello, world</p>'
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose a full email body without React; `renderVNode` accepts arbitrary
|
||||
nesting, conditional children (via array entries with `undefined` / `null`
|
||||
/ `false` filtered out), and a numeric or boolean attribute set.
|
||||
|
||||
```ts
|
||||
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
const showFooter = true;
|
||||
const layout = h("table", { role: "presentation", cellpadding: 0 }, [
|
||||
h("tr", {}, h("td", { style: { padding: "16px" } }, "Hi Jane,")),
|
||||
showFooter && h("tr", {}, h("td", { align: "center" }, "Footer")),
|
||||
]);
|
||||
|
||||
renderVNode(layout);
|
||||
```
|
||||
|
||||
The renderer never inspects globals, never touches `process.env`, and never
|
||||
calls `Date.now()` — it runs identically in Bun, Node, and the browser.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------ | -------- | ----------------------------------------------------------------- |
|
||||
| `h` | function | Hyperscript factory returning a `VNode`. |
|
||||
| `renderVNode` | function | Walks a node or text, returning HTML. |
|
||||
| `renderNodes` | function | Renders a list of nodes/strings as concatenated HTML. |
|
||||
| `escapeHtml` | function | HTML text-content escape (`&<>"'`). |
|
||||
| `escapeAttr` | function | HTML attribute-value escape (`&"<>`). |
|
||||
| `serialiseStyle` | function | CSS object → inline string with kebab-cased keys. |
|
||||
| `rawHtml` | function | Escape-hatch raw-HTML embed for trusted internal fragments. |
|
||||
| `VNode` | type | The tree-node interface. |
|
||||
| `EmailNode` | type | `VNode \| string`. |
|
||||
| `EmailChildren` | type | Children accepted by `h(...)`. |
|
||||
| `StyleObject` | type | Style attribute shape. |
|
||||
| `AttrValue` | type | Permitted attribute value type. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The renderer deliberately serialises `style` objects with the **input**
|
||||
key order (insertion order, which is stable in modern JS engines). It does
|
||||
not sort or dedupe — components are expected to emit a single `style`
|
||||
object per element. CSS property values are coerced via `String(v)`, so
|
||||
numeric pixel-less values (e.g. `lineHeight: 1.5`) survive the round-trip
|
||||
intact.
|
||||
|
||||
The `__raw__` tag is reserved for internal use; if a component were to
|
||||
emit `h("__raw__", ...)` directly, the renderer would treat it the same as
|
||||
`rawHtml(...)` — children are emitted verbatim. The factory does NOT
|
||||
expose this tag publicly because it bypasses escaping.
|
||||
|
||||
Self-closing detection is a tag-name set lookup; the lookup is
|
||||
case-insensitive (`Img` → `img`). For non-HTML emitters the caller may
|
||||
override via the fourth argument.
|
||||
Reference in New Issue
Block a user