@broberg/mail-core 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/dist/index.cjs +72 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -2
- package/dist/index.d.ts +29 -2
- package/dist/index.js +69 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,6 +159,14 @@ looked fine. This hits the careful consumer, not the careless one.
|
|
|
159
159
|
No `height` attribute is emitted: this package serves non-square logos, and a
|
|
160
160
|
forced square distorts them in exactly the client that honours attributes.
|
|
161
161
|
|
|
162
|
+
**Rendering a logo without `logoWidth` warns once per process.** It changes no
|
|
163
|
+
mail — it is there so you find out whether *your* asset is affected, rather than
|
|
164
|
+
us guessing on your behalf. Known so far: one consumer's mark is 480×480 (2× for
|
|
165
|
+
a 40px logo, the correct call by their supplier) and was therefore broken in
|
|
166
|
+
Outlook. How many others is unmeasured, because only your repo knows your file's
|
|
167
|
+
width. If the warning fires and your source is under 180px wide, you were fine;
|
|
168
|
+
if it is wider, pass `logoWidth`.
|
|
169
|
+
|
|
162
170
|
`logoWidth` is how you **draw** the logo. If you also need to **produce** it at a
|
|
163
171
|
sane size, that is `@broberg/media-transform` — complementary, not an alternative.
|
|
164
172
|
|
|
@@ -194,6 +202,69 @@ their inbox, where nobody is watching. If you call both, compose them into one
|
|
|
194
202
|
function so a call site cannot get the order wrong. (Filed by cardmem, who hit it
|
|
195
203
|
in their own template store.)
|
|
196
204
|
|
|
205
|
+
## A brand colour is a SURFACE and a TEXT colour, and it is rarely both
|
|
206
|
+
|
|
207
|
+
`accentColor` used to be printed straight into both jobs — the top bar and the
|
|
208
|
+
cta background (surfaces), *and* the eyebrow and footer link (text) — while the
|
|
209
|
+
cta's own label was hardcoded `#ffffff`. For a dark brand that happens to work.
|
|
210
|
+
For a light one it produces a mail nobody can read, and **nothing errors**: it
|
|
211
|
+
renders, it sends, and it looks like a deliberately pale style.
|
|
212
|
+
|
|
213
|
+
Measured on WebHouse gold `#F7BB2E`, reported by the cms session after their
|
|
214
|
+
Lens contrast critic caught it on a real form notification:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
accent as TEXT on white 1.74:1
|
|
218
|
+
accent as TEXT on the footer's #f4f4f5 1.58:1
|
|
219
|
+
WHITE label on the accent surface 1.74:1 ← was hardcoded
|
|
220
|
+
dark label on the accent surface 10.03:1
|
|
221
|
+
WCAG AA wants 4.5:1
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**No fixed label colour can be correct.** On `#0f7391` white measures 5.41 and
|
|
225
|
+
dark 3.22; on gold it is the exact reverse. Only this package sees both sides of
|
|
226
|
+
the pair, so from 0.8.0 it picks:
|
|
227
|
+
|
|
228
|
+
| where the accent is… | what happens |
|
|
229
|
+
| --- | --- |
|
|
230
|
+
| the top bar, the cta background, a border — a **surface** | **untouched**, exactly your brand |
|
|
231
|
+
| the cta **label** | `readableInk()` — the shell ink that contrasts more |
|
|
232
|
+
| the eyebrow, the footer link — **text** | `readableAccent()` — darkened (or lightened) only if below 4.5:1 |
|
|
233
|
+
|
|
234
|
+
Two exported helpers you can use for your own assertions:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { contrastRatio, readableInk, readableAccent } from "@broberg/mail-core";
|
|
238
|
+
|
|
239
|
+
contrastRatio("#ffffff", "#F7BB2E") // 1.74… (null if either is not a hex)
|
|
240
|
+
readableInk("#F7BB2E") // "#1a1a1a"
|
|
241
|
+
readableAccent("#F7BB2E", "#f4f4f5") // a darker gold that clears AA
|
|
242
|
+
readableAccent("#0f7391", "#f4f4f5") // "#0f7391" — already legible, untouched
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Nothing moves if your brand was already legible
|
|
246
|
+
|
|
247
|
+
`readableAccent` returns its input unchanged above 4.5:1, so a dark accent
|
|
248
|
+
renders **byte-identically**. Proven against the published 0.7.0 across seven
|
|
249
|
+
shapes (shell, footer, logo, cta, eyebrow, factBox, noteBox) — not against our
|
|
250
|
+
own previous build, which is a different artefact.
|
|
251
|
+
|
|
252
|
+
`SHELL_VERSION` still moved to `3`, because the output **does** change for a
|
|
253
|
+
light brand. That is what the marker is for: you need to be able to tell "my
|
|
254
|
+
template changed" from "the shared shell changed".
|
|
255
|
+
|
|
256
|
+
### Two things worth stealing
|
|
257
|
+
|
|
258
|
+
**Do not measure against white.** The footer sits on `#f4f4f5`. `#767676` clears
|
|
259
|
+
AA against white at 4.54 and fails at 4.13 there — so a helper that used white as
|
|
260
|
+
a stand-in would ship an illegible footer link and pass its own test. Measure
|
|
261
|
+
against the surface the text actually sits on.
|
|
262
|
+
|
|
263
|
+
**Perceived brightness is not contrast.** `#0078fa` reads as "dark" to the
|
|
264
|
+
BT.601 formula, so a brightness-based pick chooses white (4.14) over the dark ink
|
|
265
|
+
(4.20). Swept the colour cube in steps of 5: the two disagree on **14,440**
|
|
266
|
+
colours. This is a region, not an edge case.
|
|
267
|
+
|
|
197
268
|
## `SHELL_VERSION` — and what it was worth before 0.6.0
|
|
198
269
|
|
|
199
270
|
`SHELL_VERSION` is emitted into every mail as `<!-- @broberg/mail-core shell vN -->`
|
package/dist/index.cjs
CHANGED
|
@@ -31,6 +31,55 @@ function assertFontStack(field, value) {
|
|
|
31
31
|
`@broberg/mail-core: ${field} contains a character that can break out of the attribute it is rendered into (received ${JSON.stringify(value)}). Use single quotes for family names: "-apple-system,'Segoe UI',sans-serif".`
|
|
32
32
|
);
|
|
33
33
|
}
|
|
34
|
+
function relativeLuminance(r, g, b) {
|
|
35
|
+
const lin = (c) => {
|
|
36
|
+
const v = c / 255;
|
|
37
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
38
|
+
};
|
|
39
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
40
|
+
}
|
|
41
|
+
function parseHex(value) {
|
|
42
|
+
const v = value.trim();
|
|
43
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v);
|
|
44
|
+
if (!m) return null;
|
|
45
|
+
const h = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
|
|
46
|
+
const n = parseInt(h, 16);
|
|
47
|
+
return [n >> 16 & 255, n >> 8 & 255, n & 255];
|
|
48
|
+
}
|
|
49
|
+
function contrastRatio(a, b) {
|
|
50
|
+
const x = parseHex(a), y = parseHex(b);
|
|
51
|
+
if (!x || !y) return null;
|
|
52
|
+
const la = relativeLuminance(...x), lb = relativeLuminance(...y);
|
|
53
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
54
|
+
}
|
|
55
|
+
function readableInk(surface) {
|
|
56
|
+
const dark = contrastRatio("#1a1a1a", surface);
|
|
57
|
+
const light = contrastRatio("#ffffff", surface);
|
|
58
|
+
if (dark === null || light === null) return "#ffffff";
|
|
59
|
+
return dark > light ? "#1a1a1a" : "#ffffff";
|
|
60
|
+
}
|
|
61
|
+
function readableAccent(accent, surface) {
|
|
62
|
+
const current = contrastRatio(accent, surface);
|
|
63
|
+
if (current === null) return accent;
|
|
64
|
+
if (current >= 4.5) return accent;
|
|
65
|
+
const rgb = parseHex(accent);
|
|
66
|
+
const surf = parseHex(surface);
|
|
67
|
+
const goDarker = relativeLuminance(...surf) > 0.5;
|
|
68
|
+
const hex = (c) => "#" + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join("");
|
|
69
|
+
let best = accent, bestRatio = current;
|
|
70
|
+
for (let i = 1; i <= 40; i++) {
|
|
71
|
+
const t = i / 40;
|
|
72
|
+
const candidate = goDarker ? [rgb[0] * (1 - t), rgb[1] * (1 - t), rgb[2] * (1 - t)] : [rgb[0] + (255 - rgb[0]) * t, rgb[1] + (255 - rgb[1]) * t, rgb[2] + (255 - rgb[2]) * t];
|
|
73
|
+
const h = hex(candidate);
|
|
74
|
+
const r = contrastRatio(h, surface);
|
|
75
|
+
if (r > bestRatio) {
|
|
76
|
+
best = h;
|
|
77
|
+
bestRatio = r;
|
|
78
|
+
}
|
|
79
|
+
if (r >= 4.5) return h;
|
|
80
|
+
}
|
|
81
|
+
return best;
|
|
82
|
+
}
|
|
34
83
|
function isDark(hex) {
|
|
35
84
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
36
85
|
if (!m) return false;
|
|
@@ -60,13 +109,25 @@ function resolveLogoSrc(logo, fallbackUrl) {
|
|
|
60
109
|
if (/^data:/i.test(url)) return null;
|
|
61
110
|
return url;
|
|
62
111
|
}
|
|
63
|
-
var SHELL_VERSION = "
|
|
112
|
+
var SHELL_VERSION = "3";
|
|
113
|
+
var warnedUnsizedLogo = false;
|
|
114
|
+
function warnUnsizedLogo() {
|
|
115
|
+
if (warnedUnsizedLogo) return;
|
|
116
|
+
warnedUnsizedLogo = true;
|
|
117
|
+
console.warn(
|
|
118
|
+
"@broberg/mail-core: rendering a logo without `logoWidth`. Outlook ignores CSS dimensions on an image, so it will draw your file at its FULL width there \u2014 a 480px source becomes a 480px logo. Pass logoWidth (e.g. { logoWidth: 56 }) even if 180 is what you want. This warns once per process."
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
function __resetLogoWarning() {
|
|
122
|
+
warnedUnsizedLogo = false;
|
|
123
|
+
}
|
|
64
124
|
function renderShell(opts) {
|
|
65
125
|
const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);
|
|
66
126
|
const lang = opts.lang ?? "en";
|
|
67
127
|
const showFooter = opts.showFooter ?? true;
|
|
68
128
|
const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);
|
|
69
129
|
const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? "";
|
|
130
|
+
if (logoSrc && opts.logoWidth === void 0) warnUnsizedLogo();
|
|
70
131
|
const logoW = typeof opts.logoWidth === "number" && Number.isFinite(opts.logoWidth) && opts.logoWidth > 0 ? Math.round(opts.logoWidth) : null;
|
|
71
132
|
const logoBlock = logoSrc ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto 16px;">
|
|
72
133
|
<tr><td>
|
|
@@ -77,7 +138,7 @@ function renderShell(opts) {
|
|
|
77
138
|
const footerBlock = showFooter ? `<tr>
|
|
78
139
|
<td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};">
|
|
79
140
|
${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;color:${footerText};">${escapeHtml(l)}</p>`).join("")}
|
|
80
|
-
${opts.footerHref ? `<p style="margin:0;font-size:11px;"><a href="${escapeAttr(opts.footerHref)}" style="color:${accentColor};text-decoration:none;font-weight:600;">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : ""}
|
|
141
|
+
${opts.footerHref ? `<p style="margin:0;font-size:11px;"><a href="${escapeAttr(opts.footerHref)}" style="color:${readableAccent(accentColor, backdropColor)};text-decoration:none;font-weight:600;">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : ""}
|
|
81
142
|
</td>
|
|
82
143
|
</tr>` : "";
|
|
83
144
|
return `<!doctype html>
|
|
@@ -166,7 +227,9 @@ function heading(text, opts) {
|
|
|
166
227
|
}
|
|
167
228
|
function eyebrow(text, opts) {
|
|
168
229
|
assertColor("accentColor", opts.accentColor);
|
|
169
|
-
|
|
230
|
+
assertColor("surface", opts.surface);
|
|
231
|
+
const colour = readableAccent(opts.accentColor, opts.surface ?? "#fffffe");
|
|
232
|
+
return `<p style="margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${colour};text-align:center;">${escapeHtml(text)}</p>`;
|
|
170
233
|
}
|
|
171
234
|
function noteBox(html, opts) {
|
|
172
235
|
assertColor("accentColor", opts.accentColor);
|
|
@@ -201,10 +264,11 @@ function signOff(a, b, sign) {
|
|
|
201
264
|
}
|
|
202
265
|
function cta(href, label, opts) {
|
|
203
266
|
assertColor("accentColor", opts.accentColor);
|
|
267
|
+
const ink = readableInk(opts.accentColor);
|
|
204
268
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:28px auto 8px;">
|
|
205
269
|
<tr>
|
|
206
270
|
<td bgcolor="${opts.accentColor}" style="background:${opts.accentColor};border-radius:999px;">
|
|
207
|
-
<a href="${escapeAttr(href)}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color
|
|
271
|
+
<a href="${escapeAttr(href)}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:${ink};text-decoration:none;">${escapeHtml(label)}</a>
|
|
208
272
|
</td>
|
|
209
273
|
</tr>
|
|
210
274
|
</table>`;
|
|
@@ -247,8 +311,10 @@ function makeLogoAttachment(filePath, opts) {
|
|
|
247
311
|
}
|
|
248
312
|
|
|
249
313
|
exports.SHELL_VERSION = SHELL_VERSION;
|
|
314
|
+
exports.__resetLogoWarning = __resetLogoWarning;
|
|
250
315
|
exports.assertColor = assertColor;
|
|
251
316
|
exports.assertFontStack = assertFontStack;
|
|
317
|
+
exports.contrastRatio = contrastRatio;
|
|
252
318
|
exports.cta = cta;
|
|
253
319
|
exports.escapeAttr = escapeAttr;
|
|
254
320
|
exports.escapeHtml = escapeHtml;
|
|
@@ -261,6 +327,8 @@ exports.makeLogoAttachment = makeLogoAttachment;
|
|
|
261
327
|
exports.noteBox = noteBox;
|
|
262
328
|
exports.paragraph = paragraph;
|
|
263
329
|
exports.paragraphHtml = paragraphHtml;
|
|
330
|
+
exports.readableAccent = readableAccent;
|
|
331
|
+
exports.readableInk = readableInk;
|
|
264
332
|
exports.renderShell = renderShell;
|
|
265
333
|
exports.resolveLogoSrc = resolveLogoSrc;
|
|
266
334
|
exports.signOff = signOff;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["existsSync","readFileSync"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#fffffe' — one byte off white on purpose, so a\n * client looking for EXACTLY #ffffff does not decide the mail wants\n * inverting. Pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\n/** The CSS named colours. The full set on purpose: a guard that rejects\n * `rebeccapurple` is one consumers route around, and a routed-around guard\n * protects nothing. (F023.9 constraint.) */\nconst NAMED_COLORS = new Set(\n (\"aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue \" +\n \"blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk \" +\n \"crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki \" +\n \"darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen \" +\n \"darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue \" +\n \"dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite \" +\n \"gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki \" +\n \"lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan \" +\n \"lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen \" +\n \"lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen \" +\n \"magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen \" +\n \"mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream \" +\n \"mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid \" +\n \"palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum \" +\n \"powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown \" +\n \"seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen \" +\n \"steelblue tan teal thistle tomato transparent turquoise violet wheat white whitesmoke \" +\n \"yellow yellowgreen\").split(\" \"),\n);\n\n/** ONE muted pair for the whole package, not one per function. factBox kept an\n * `opacity:0.65` for a full card after F023.8 removed it from the footer, and\n * the acceptance criterion that should have caught it (\"no opacity on any text\n * in the shell\") passed because its test rendered renderShell and not factBox.\n * A single pair means the next primitive cannot invent a third mid-tone.\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #484848 5.18:1 */\nconst MUTED_LIGHT = \"#4a4d63\";\nconst MUTED_DARK = \"#c1c2d1\";\n\nconst HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;\nconst FUNCTIONAL = /^(?:rgb|rgba|hsl|hsla)\\(\\s*[0-9a-z.%,\\s/+-]+\\)$/i;\n\n/** Reject a brand colour that is not a colour. **REJECT, never escape** — an\n * escaped non-colour still leaves the building and still renders as literal\n * garbage inside a `style` attribute, so the customer sees a broken mail and\n * nobody sees an error. Throwing fails at the CALLER, where someone can act.\n *\n * PROVEN REACHABLE, 2026-09-03, against the built package (F023.9):\n * accentColor = '#0f7391\" onmouseover=\"alert(1)\" x=\"'\n * -> <td bgcolor=\"#0f7391\" onmouseover=\"alert(1)\" x=\"\" ...>\n * a longer payload injected a complete\n * <a href=\"https://phish.example\">Log ind her</a>\n * into the rendered mail. No script needed: a login link inside an otherwise\n * genuine, correctly-branded transactional mail IS the attack, and clients\n * that strip script still render the anchor.\n *\n * It was not reachable when this was written — a single-tenant repo passes a\n * constant from a config file and has no attacker. xrt81 now resolves branding\n * PER TENANT from a database and cardmem's template store is being built. The\n * assumption did not become false through carelessness; the deployment model\n * moved underneath it. */\nexport function assertColor(field: string, value: string | undefined): void {\n if (value === undefined) return;\n const v = value.trim();\n if (HEX.test(v) || FUNCTIONAL.test(v) || NAMED_COLORS.has(v.toLowerCase())) return;\n throw new Error(\n `@broberg/mail-core: ${field} is not a CSS colour (received ${JSON.stringify(value)}). ` +\n `Brand values are interpolated into HTML attributes, so an arbitrary string here can ` +\n `inject markup into the mail. Pass a hex, rgb()/rgba(), hsl()/hsla(), or a named colour.`,\n );\n}\n\n/** A font stack is NOT a colour and must not borrow the colour grammar — it\n * legitimately contains quotes and commas (`'Segoe UI'`). What cannot appear is\n * a tag delimiter or a quote that closes the attribute we sit inside. */\nexport function assertFontStack(field: string, value: string | undefined): void {\n if (value === undefined) return;\n if (!/[<>\"`]/.test(value)) return;\n throw new Error(\n `@broberg/mail-core: ${field} contains a character that can break out of the ` +\n `attribute it is rendered into (received ${JSON.stringify(value)}). ` +\n `Use single quotes for family names: \"-apple-system,'Segoe UI',sans-serif\".`,\n );\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n // Driven from the FIELD NAMES rather than a hand-written list of call sites:\n // a list of seven line numbers goes stale the next time this file is edited,\n // and staleness here reads as coverage. (F023.9 AC#2.)\n assertColor(\"accentColor\", b.accentColor);\n assertColor(\"cardBg\", b.cardBg);\n assertColor(\"textColor\", b.textColor);\n assertColor(\"backdropColor\", b.backdropColor);\n assertFontStack(\"fontSans\", b.fontSans);\n assertFontStack(\"fontSerif\", b.fontSerif);\n\n // #fffffe, not #ffffff, and the one-off byte is the whole point: several\n // clients treat EXACTLY white as \"this is a light mail, invert it\". One step\n // off slips that recognition and no eye can tell the difference. Measured at\n // ZERO effect in Outlook iOS specifically (F023.7) — it is on the list because\n // it works in OTHER clients, not because it rescues that one.\n const cardBg = b.cardBg ?? \"#fffffe\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** How wide to DRAW the logo, in px. Omit and you get the historic centred\n * slot unchanged (`max-width:180px`, no width attribute) — byte-identical to\n * every mail sent before this field existed.\n *\n * SET IT IF YOU CAN, and set it even when 180 is what you want: a supplied\n * width is emitted as an HTML `width` ATTRIBUTE as well as in the style, and\n * **the attribute is the only half Outlook reads.** Outlook's Word engine\n * ignores CSS dimensions on an image, so without the attribute it draws the\n * mark at its full FILE size.\n *\n * WHICH IS WHY THIS EXISTS: vn-leker shipped a 480×480 mark — 2× for a 40px\n * logo, the correct decision — and the shell drew it 180px wide on a 520px\n * card. Christian opened it in Gmail: «Alt for stort logo». **The better the\n * source you supply, the worse the result**; a 96px file would have looked\n * fine. The careful consumer is the one this hits.\n *\n * No `height` attribute is emitted, deliberately: this package serves\n * non-square logos, and a forced square distorts them in exactly the client\n * that honours attributes. */\n logoWidth?: number;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"2\";\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n // A supplied width is emitted as an ATTRIBUTE as well as in the style, because\n // the attribute is the half Outlook reads. Omitted keeps the historic block\n // byte-for-byte — existing production mail must not shift under consumers who\n // never asked for anything.\n //\n // KNOWN AND DELIBERATE: the DEFAULT therefore stays Outlook-unsafe. A caller\n // who omits logoWidth still gets a mark drawn at its full file size in\n // Outlook. Making 180 emit an attribute would fix that for everyone and would\n // change what every existing consumer's mail looks like in one client, which\n // is not a change to make silently. Set logoWidth explicitly.\n const logoW =\n typeof opts.logoWidth === \"number\" && Number.isFinite(opts.logoWidth) && opts.logoWidth > 0\n ? Math.round(opts.logoWidth)\n : null;\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\"${logoW ? ` width=\"${logoW}\"` : \"\"} style=\"display:block;margin:0 auto;${logoW ? `width:${logoW}px` : \"max-width:180px\"};height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n // The footer zone is carried by a COLOURED RULE, not by its fill. fd-sundhed\n // measured card and footer BOTH becoming #484848 in Outlook iOS — the fill\n // stopped distinguishing anything and the zone ceased to exist. What survived\n // was a rule in the brand's own accent. The previous rgba(0,0,0,0.08) is a\n // near-invisible black alpha, i.e. exactly the thing that disappears there.\n //\n // And the text is a real COLOUR, never an opacity. An opacity is not a low\n // contrast value — it is a contrast value FOR ONE BACKGROUND: opacity 0.65 of\n // #1a1c2b measures 5.29:1 while the ground stays white, and lands somewhere\n // nobody measured the moment a client tints or inverts. No contrast tool can\n // read it, because there is no colour there to read.\n // #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #1a1c2b 9.56:1\n // #4a4d63 on #ffffff 8.29:1 #c1c2d1 on #484848 5.18:1 (the mapped case)\n const footerText = isDark(backdropColor) ? MUTED_DARK : MUTED_LIGHT;\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;color:${footerText};\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\"> </td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n assertColor(\"accentColor\", opts?.accentColor);\n assertColor(\"textColor\", opts?.textColor);\n assertFontStack(\"fontSerif\", opts?.fontSerif);\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\n/** One line of a signature, and the tier that styles it.\n *\n * THE INVARIANT, and it is testable rather than a matter of taste: **each tier\n * changes exactly ONE axis against `lead`.** There is no fourth tier waiting,\n * because there is no fourth axis left to spend.\n *\n * lead the base — the size and colour of the surrounding text\n * name + bold (same size, same colour)\n * meta + muted colour (same size, same weight)\n *\n * WHY `name` IS NOT ALSO DARKER, though the obvious signature makes it so:\n * measured on vn-leker's own palette, #1a1c2b is 16.86:1 on white and #0b0e15\n * is 19.29:1. Both are so far past every threshold that the step cannot be\n * seen. The weight does all the work; the colour shift was decoration. Their\n * finding, on their own design.\n *\n * WHY `meta` HAS NO SIZE OF ITS OWN, which is the tempting third axis: a tier\n * carrying a *relative* size step turns a 17/17-bold/15 signature into\n * 15/15-bold/13 in a palette with a smaller base — and 13px secondary text is\n * the exact thing fd-sundhed measured their way out of (13.5px #8486a6 at\n * 3.5:1, failing WCAG in LIGHT mode, before anyone mentioned dark). They went\n * UP in size as part of what doubled legibility. A relative step would quietly\n * roll that back, and the fault would live in a tier definition nobody reads\n * while choosing `meta`. 15px is a measured floor for secondary text in mail.\n */\nexport interface SignOffLine {\n text: string;\n tier?: \"lead\" | \"name\" | \"meta\";\n}\n\n/** The muted tier's colour, one value per background polarity — never an\n * `opacity`, for the reason spelled out on the footer above: an opacity is a\n * contrast value for ONE background only.\n *\n * BOTH POLARITIES EXIST BECAUSE THE SHELL SUPPORTS DARK CARDS, and the first\n * cut of this function did not: a hardcoded #4a4d63 measures **2.10:1** on a\n * #1a1a1a card — far under the 4.5:1 floor, while the README advertises dark\n * cards as a supported mode. That is the same defect this change removed from\n * the footer, reintroduced one function away in the same commit. Found by\n * reviewing the diff, not by any test — which is why the test now renders BOTH\n * polarities and asserts they DIFFER.\n *\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #1a1a1a 2.10:1 <- #c1c2d1 on #484848 5.18:1\n */\nconst SIGNOFF_META_LIGHT = MUTED_LIGHT;\nconst SIGNOFF_META_DARK = MUTED_DARK;\n\nfunction signOffLine(line: SignOffLine, metaColor: string): string {\n const text = escapeHtml(line.text);\n if (line.tier === \"name\") return `<strong style=\"font-weight:700;\">${text}</strong>`;\n if (line.tier === \"meta\") return `<span style=\"color:${metaColor};\">${text}</span>`;\n return text;\n}\n\n/** A signature block.\n *\n * TWO FORMS, and the old one is load-bearing: three repos call\n * `signOff(line1, line2, sign)` in production mail, so it renders\n * byte-identically and always will.\n *\n * THE OLD FORM'S DEFECT, which is why the array form exists: its big slot is\n * the LAST argument and its only axis is size. A name-then-title signature had\n * to be forced into it, and rendered the job title larger than the person —\n * in a mail Christian opened. The API could not express the signature, so the\n * mapping was wrong before anyone wrote a line of calling code.\n *\n * An index-based fix (`{ emphasizeIndex }`) was proposed and rejected: it\n * would place the name and still leave the title nowhere to go, i.e. the same\n * defect in a new shape. It also defaults to index 0 — \"Med venlig hilsen\" —\n * inverting the old form's last-line emphasis for everyone who did not pass\n * the option. vn-leker caught that; it was worse than the bug it fixed.\n */\nexport function signOff(lines: SignOffLine[], opts?: { cardBg?: string }): string;\nexport function signOff(line1: string, line2: string, sign: string): string;\nexport function signOff(\n a: SignOffLine[] | string,\n b?: { cardBg?: string } | string,\n sign?: string,\n): string {\n if (Array.isArray(a) && typeof b === \"object\") assertColor(\"cardBg\", b?.cardBg);\n // The separator carries the original's indentation, so the legacy form is\n // byte-identical rather than merely equivalent. A test asserts that against a\n // stored snapshot; reading it here is not the proof.\n const br = \"<br>\\n \";\n // `meta` follows the card it sits on, using the SAME isDark() the shell uses,\n // so the two cannot drift apart. A caller who omits cardBg gets the light\n // pair, which is exactly what the shell's own default card is.\n const metaColor =\n Array.isArray(a) && typeof b === \"object\" && b?.cardBg && isDark(b.cardBg)\n ? SIGNOFF_META_DARK\n : SIGNOFF_META_LIGHT;\n const body = Array.isArray(a)\n ? a.map((l) => signOffLine(l, metaColor)).join(br)\n // The legacy form — with ONE correction: an empty `sign` used to emit a\n // trailing `<br>` plus `<span style=\"font-size:20px;\"></span>`, i.e. a blank\n // line and an empty styled element that failed nowhere and so survived.\n // vn-leker's own signature replacement left exactly that residue.\n : [escapeHtml(a), escapeHtml(typeof b === \"string\" ? b : \"\")].join(br) +\n (sign ? `${br}<span style=\"font-size:20px;\">${escapeHtml(sign)}</span>` : \"\");\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${body}\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n assertColor(\"accentColor\", opts?.accentColor);\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;color:${MUTED_LIGHT};white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace `{token}` placeholders with values. **Every value is HTML-escaped.**\n * Unknown tokens are left as-is.\n *\n * ⚠️ THE ESCAPING IS THE POINT, and it was missing until 0.6.0. `vars` is\n * dynamic BY DEFINITION — a customer's name, a booking reference, a message\n * someone typed — so every value reaching this function is exactly the class of\n * data that must be escaped. Measured on 0.5.0 and earlier:\n *\n * fill(\"<p>Hej {name}</p>\", { name: '<a href=\"https://phish.example\">Log ind</a>' })\n * -> <p>Hej <a href=\"https://phish.example\">Log ind</a></p>\n *\n * The anchor was in the mail. If you were on an earlier version and passed\n * anything user-supplied through this, assume it rendered as markup.\n *\n * Composing actual markup? Use {@link fillHtml}, whose NAME says so at the call\n * site. There is deliberately no escaping flag: a flag has to default to\n * something, and the wrong default is invisible where it is called.\n *\n * ⚠️ **ORDER MATTERS NOW THAT THIS ESCAPES — RENDER FIRST, THEN FILL.**\n * Filed by cardmem the day the escaping landed, measured in their own store:\n *\n * render THEN fill \"Sørensen & Søn\" -> \"Sørensen & Søn\" ✓\n * fill THEN render \"Sørensen & Søn\" -> \"Sørensen &amp; Søn\" ✗\n *\n * Render first and `{token}` is ordinary text that survives escaping untouched,\n * so each value is escaped exactly once — by the function that substitutes it.\n *\n * It fails in the worst available direction: perfect for every customer whose\n * name has no `&`, `<` or quote, which is most of them. It reaches production\n * looking correct and breaks on one real person, in their inbox, where nobody\n * is watching. If you call both, compose them in ONE function so a call site\n * cannot get the order wrong. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) =>\n key in vars ? escapeHtml(String(vars[key])) : `{${key}}`,\n );\n}\n\n/** Like {@link fill}, but the values are injected as **raw HTML** — nothing is\n * escaped, and the caller owns every value.\n *\n * Mirrors `paragraph` / `paragraphHtml` above: the unsafe one is the one you\n * have to name. Reach for it only when the value is markup you built yourself,\n * never for anything that reached you from a user, a database or a request. */\nexport function fillHtml(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["existsSync","readFileSync"],"mappings":";;;;;AAcO,SAAS,WAAW,CAAA,EAAmB;AAC5C,EAAA,OAAO,EAAE,OAAA,CAAQ,UAAA,EAAY,CAAC,CAAA,KAAA,CAAO,EAAE,KAAK,OAAA,EAAS,GAAA,EAAK,QAAQ,GAAA,EAAK,MAAA,EAAQ,KAAK,QAAA,EAAU,GAAA,EAAK,SAAQ,EAAG,CAAC,KAAK,CAAC,CAAA;AACvH;AAEO,SAAS,WAAW,CAAA,EAAmB;AAC5C,EAAA,OAAO,WAAW,CAAC,CAAA;AACrB;AAuBA,IAAM,eAAe,IAAI,GAAA;AAAA,EACtB,28CAAA,CAiBuB,MAAM,GAAG;AACnC,CAAA;AASA,IAAM,WAAA,GAAc,SAAA;AACpB,IAAM,UAAA,GAAa,SAAA;AAEnB,IAAM,GAAA,GAAM,+CAAA;AACZ,IAAM,UAAA,GAAa,kDAAA;AAqBZ,SAAS,WAAA,CAAY,OAAe,KAAA,EAAiC;AAC1E,EAAA,IAAI,UAAU,MAAA,EAAW;AACzB,EAAA,MAAM,CAAA,GAAI,MAAM,IAAA,EAAK;AACrB,EAAA,IAAI,GAAA,CAAI,IAAA,CAAK,CAAC,CAAA,IAAK,UAAA,CAAW,IAAA,CAAK,CAAC,CAAA,IAAK,YAAA,CAAa,GAAA,CAAI,CAAA,CAAE,WAAA,EAAa,CAAA,EAAG;AAC5E,EAAA,MAAM,IAAI,KAAA;AAAA,IACR,uBAAuB,KAAK,CAAA,+BAAA,EAAkC,IAAA,CAAK,SAAA,CAAU,KAAK,CAAC,CAAA,8KAAA;AAAA,GAGrF;AACF;AAKO,SAAS,eAAA,CAAgB,OAAe,KAAA,EAAiC;AAC9E,EAAA,IAAI,UAAU,MAAA,EAAW;AACzB,EAAA,IAAI,CAAC,QAAA,CAAS,IAAA,CAAK,KAAK,CAAA,EAAG;AAC3B,EAAA,MAAM,IAAI,KAAA;AAAA,IACR,uBAAuB,KAAK,CAAA,wFAAA,EACiB,IAAA,CAAK,SAAA,CAAU,KAAK,CAAC,CAAA,6EAAA;AAAA,GAEpE;AACF;AAwBA,SAAS,iBAAA,CAAkB,CAAA,EAAW,CAAA,EAAW,CAAA,EAAmB;AAClE,EAAA,MAAM,GAAA,GAAM,CAAC,CAAA,KAAc;AACzB,IAAA,MAAM,IAAI,CAAA,GAAI,GAAA;AACd,IAAA,OAAO,CAAA,IAAK,UAAU,CAAA,GAAI,KAAA,GAAQ,KAAK,GAAA,CAAA,CAAK,CAAA,GAAI,KAAA,IAAS,KAAA,EAAO,GAAG,CAAA;AAAA,EACrE,CAAA;AACA,EAAA,OAAO,MAAA,GAAS,GAAA,CAAI,CAAC,CAAA,GAAI,MAAA,GAAS,IAAI,CAAC,CAAA,GAAI,MAAA,GAAS,GAAA,CAAI,CAAC,CAAA;AAC3D;AAKA,SAAS,SAAS,KAAA,EAAgD;AAChE,EAAA,MAAM,CAAA,GAAI,MAAM,IAAA,EAAK;AACrB,EAAA,MAAM,CAAA,GAAI,+BAAA,CAAgC,IAAA,CAAK,CAAC,CAAA;AAChD,EAAA,IAAI,CAAC,GAAG,OAAO,IAAA;AACf,EAAA,MAAM,CAAA,GAAI,EAAE,CAAC,CAAA,CAAG,WAAW,CAAA,GAAI,CAAA,CAAE,CAAC,CAAA,CAAG,KAAA,CAAM,EAAE,EAAE,GAAA,CAAI,CAAC,MAAM,CAAA,GAAI,CAAC,EAAE,IAAA,CAAK,EAAE,CAAA,GAAI,CAAA,CAAE,CAAC,CAAA;AAC/E,EAAA,MAAM,CAAA,GAAI,QAAA,CAAS,CAAA,EAAG,EAAE,CAAA;AACxB,EAAA,OAAO,CAAE,KAAK,EAAA,GAAM,GAAA,EAAM,KAAK,CAAA,GAAK,GAAA,EAAK,IAAI,GAAG,CAAA;AAClD;AAKO,SAAS,aAAA,CAAc,GAAW,CAAA,EAA0B;AACjE,EAAA,MAAM,IAAI,QAAA,CAAS,CAAC,CAAA,EAAG,CAAA,GAAI,SAAS,CAAC,CAAA;AACrC,EAAA,IAAI,CAAC,CAAA,IAAK,CAAC,CAAA,EAAG,OAAO,IAAA;AACrB,EAAA,MAAM,EAAA,GAAK,kBAAkB,GAAG,CAAC,GAAG,EAAA,GAAK,iBAAA,CAAkB,GAAG,CAAC,CAAA;AAC/D,EAAA,OAAA,CAAQ,IAAA,CAAK,GAAA,CAAI,EAAA,EAAI,EAAE,CAAA,GAAI,SAAS,IAAA,CAAK,GAAA,CAAI,EAAA,EAAI,EAAE,CAAA,GAAI,IAAA,CAAA;AACzD;AAKO,SAAS,YAAY,OAAA,EAAyB;AACnD,EAAA,MAAM,IAAA,GAAO,aAAA,CAAc,SAAA,EAAW,OAAO,CAAA;AAC7C,EAAA,MAAM,KAAA,GAAQ,aAAA,CAAc,SAAA,EAAW,OAAO,CAAA;AAC9C,EAAA,IAAI,IAAA,KAAS,IAAA,IAAQ,KAAA,KAAU,IAAA,EAAM,OAAO,SAAA;AAC5C,EAAA,OAAO,IAAA,GAAO,QAAQ,SAAA,GAAY,SAAA;AACpC;AAeO,SAAS,cAAA,CAAe,QAAgB,OAAA,EAAyB;AACtE,EAAA,MAAM,OAAA,GAAU,aAAA,CAAc,MAAA,EAAQ,OAAO,CAAA;AAC7C,EAAA,IAAI,OAAA,KAAY,MAAM,OAAO,MAAA;AAC7B,EAAA,IAAI,OAAA,IAAW,KAAK,OAAO,MAAA;AAE3B,EAAA,MAAM,GAAA,GAAM,SAAS,MAAM,CAAA;AAC3B,EAAA,MAAM,IAAA,GAAO,SAAS,OAAO,CAAA;AAC7B,EAAA,MAAM,QAAA,GAAW,iBAAA,CAAkB,GAAG,IAAI,CAAA,GAAI,GAAA;AAC9C,EAAA,MAAM,GAAA,GAAM,CAAC,CAAA,KACX,GAAA,GAAM,CAAA,CAAE,GAAA,CAAI,CAAC,CAAA,KAAM,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,GAAA,CAAI,GAAA,EAAK,IAAA,CAAK,KAAA,CAAM,CAAC,CAAC,CAAC,CAAA,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,QAAA,CAAS,CAAA,EAAG,GAAG,CAAC,CAAA,CAAE,KAAK,EAAE,CAAA;AAKrG,EAAA,IAAI,IAAA,GAAO,QAAQ,SAAA,GAAY,OAAA;AAC/B,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,IAAK,EAAA,EAAI,CAAA,EAAA,EAAK;AAC5B,IAAA,MAAM,IAAI,CAAA,GAAI,EAAA;AACd,IAAA,MAAM,YAAsC,QAAA,GACxC,CAAC,IAAI,CAAC,CAAA,IAAK,IAAI,CAAA,CAAA,EAAI,GAAA,CAAI,CAAC,CAAA,IAAK,CAAA,GAAI,IAAI,GAAA,CAAI,CAAC,KAAK,CAAA,GAAI,CAAA,CAAE,IACrD,CAAC,GAAA,CAAI,CAAC,CAAA,GAAA,CAAK,GAAA,GAAM,IAAI,CAAC,CAAA,IAAK,GAAG,GAAA,CAAI,CAAC,KAAK,GAAA,GAAM,GAAA,CAAI,CAAC,CAAA,IAAK,CAAA,EAAG,IAAI,CAAC,CAAA,GAAA,CAAK,MAAM,GAAA,CAAI,CAAC,KAAK,CAAC,CAAA;AAC1F,IAAA,MAAM,CAAA,GAAI,IAAI,SAAS,CAAA;AACvB,IAAA,MAAM,CAAA,GAAI,aAAA,CAAc,CAAA,EAAG,OAAO,CAAA;AAClC,IAAA,IAAI,IAAI,SAAA,EAAW;AAAE,MAAA,IAAA,GAAO,CAAA;AAAG,MAAA,SAAA,GAAY,CAAA;AAAA,IAAG;AAC9C,IAAA,IAAI,CAAA,IAAK,KAAK,OAAO,CAAA;AAAA,EACvB;AACA,EAAA,OAAO,IAAA;AACT;AAEA,SAAS,OAAO,GAAA,EAAsB;AACpC,EAAA,MAAM,CAAA,GAAI,oBAAA,CAAqB,IAAA,CAAK,GAAA,CAAI,MAAM,CAAA;AAC9C,EAAA,IAAI,CAAC,GAAG,OAAO,KAAA;AACf,EAAA,MAAM,CAAA,GAAI,QAAA,CAAS,CAAA,CAAE,CAAC,GAAG,EAAE,CAAA;AAC3B,EAAA,MAAM,CAAA,GAAK,KAAK,EAAA,GAAM,GAAA,EAAK,IAAK,CAAA,IAAK,CAAA,GAAK,GAAA,EAAK,CAAA,GAAI,CAAA,GAAI,GAAA;AAEvD,EAAA,OAAA,CAAQ,IAAI,GAAA,GAAM,CAAA,GAAI,GAAA,GAAM,CAAA,GAAI,OAAO,GAAA,GAAO,GAAA;AAChD;AAEA,SAAS,cAAc,CAAA,EAAgB;AAIrC,EAAA,WAAA,CAAY,aAAA,EAAe,EAAE,WAAW,CAAA;AACxC,EAAA,WAAA,CAAY,QAAA,EAAU,EAAE,MAAM,CAAA;AAC9B,EAAA,WAAA,CAAY,WAAA,EAAa,EAAE,SAAS,CAAA;AACpC,EAAA,WAAA,CAAY,eAAA,EAAiB,EAAE,aAAa,CAAA;AAC5C,EAAA,eAAA,CAAgB,UAAA,EAAY,EAAE,QAAQ,CAAA;AACtC,EAAA,eAAA,CAAgB,WAAA,EAAa,EAAE,SAAS,CAAA;AAOxC,EAAA,MAAM,MAAA,GAAS,EAAE,MAAA,IAAU,SAAA;AAC3B,EAAA,MAAM,YAAY,CAAA,CAAE,SAAA,KAAc,MAAA,CAAO,MAAM,IAAI,SAAA,GAAY,SAAA,CAAA;AAC/D,EAAA,MAAM,aAAA,GAAgB,EAAE,aAAA,IAAiB,SAAA;AACzC,EAAA,MAAM,QAAA,GAAW,EAAE,QAAA,IAAY,+DAAA;AAC/B,EAAA,MAAM,SAAA,GAAY,EAAE,SAAA,IAAa,iCAAA;AACjC,EAAA,OAAO,EAAE,aAAa,CAAA,CAAE,WAAA,EAAa,QAAQ,SAAA,EAAW,aAAA,EAAe,UAAU,SAAA,EAAU;AAC7F;AAqEO,SAAS,cAAA,CAAe,MAA8B,WAAA,EAAqC;AAChG,EAAA,MAAM,GAAA,GAAM,IAAA,EAAM,GAAA,EAAK,IAAA,EAAK;AAC5B,EAAA,IAAI,GAAA,EAAK,OAAO,CAAA,IAAA,EAAO,GAAG,CAAA,CAAA;AAC1B,EAAA,MAAM,MAAM,IAAA,EAAM,GAAA,EAAK,IAAA,EAAK,IAAK,aAAa,IAAA,EAAK;AACnD,EAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AAIjB,EAAA,IAAI,SAAA,CAAU,IAAA,CAAK,GAAG,CAAA,EAAG,OAAO,IAAA;AAChC,EAAA,OAAO,GAAA;AACT;AAoBO,IAAM,aAAA,GAAgB;AA0B7B,IAAI,iBAAA,GAAoB,KAAA;AACxB,SAAS,eAAA,GAAwB;AAC/B,EAAA,IAAI,iBAAA,EAAmB;AACvB,EAAA,iBAAA,GAAoB,IAAA;AAEpB,EAAA,OAAA,CAAQ,IAAA;AAAA,IACN;AAAA,GAIF;AACF;AAKO,SAAS,kBAAA,GAA2B;AACzC,EAAA,iBAAA,GAAoB,KAAA;AACtB;AAEO,SAAS,YAAY,IAAA,EAAyB;AACnD,EAAA,MAAM,EAAE,aAAa,MAAA,EAAQ,SAAA,EAAW,eAAe,QAAA,EAAS,GAAI,cAAc,IAAI,CAAA;AACtF,EAAA,MAAM,IAAA,GAAO,KAAK,IAAA,IAAQ,IAAA;AAC1B,EAAA,MAAM,UAAA,GAAa,KAAK,UAAA,IAAc,IAAA;AAEtC,EAAA,MAAM,OAAA,GAAU,cAAA,CAAe,IAAA,CAAK,IAAA,EAAM,KAAK,OAAO,CAAA;AACtD,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,IAAA,EAAM,GAAA,IAAO,KAAK,OAAA,IAAW,EAAA;AAWlD,EAAA,IAAI,OAAA,IAAW,IAAA,CAAK,SAAA,KAAc,MAAA,EAAW,eAAA,EAAgB;AAC7D,EAAA,MAAM,QACJ,OAAO,IAAA,CAAK,SAAA,KAAc,QAAA,IAAY,OAAO,QAAA,CAAS,IAAA,CAAK,SAAS,CAAA,IAAK,KAAK,SAAA,GAAY,CAAA,GACtF,KAAK,KAAA,CAAM,IAAA,CAAK,SAAS,CAAA,GACzB,IAAA;AACN,EAAA,MAAM,YAAY,OAAA,GACd,CAAA;AAAA;AAAA,gBAAA,EAEY,WAAW,OAAO,CAAC,UAAU,UAAA,CAAW,OAAO,CAAC,CAAA,CAAA,EAAI,KAAA,GAAQ,CAAA,QAAA,EAAW,KAAK,MAAM,EAAE,CAAA,oCAAA,EAAuC,QAAQ,CAAA,MAAA,EAAS,KAAK,OAAO,iBAAiB,CAAA;AAAA;AAAA,UAAA,CAAA,GAGrL,EAAA;AAeJ,EAAA,MAAM,UAAA,GAAa,MAAA,CAAO,aAAa,CAAA,GAAI,UAAA,GAAa,WAAA;AACxD,EAAA,MAAM,cAAc,UAAA,GAChB,CAAA;AAAA,mBAAA,EACe,aAAa,CAAA,oBAAA,EAAuB,aAAa,CAAA,+DAAA,EAAkE,WAAW,CAAA;AAAA,QAAA,EAAA,CACxI,KAAK,WAAA,IAAe,EAAC,EAAG,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,8CAAA,EAAiD,UAAU,CAAA,GAAA,EAAM,WAAW,CAAC,CAAC,MAAM,CAAA,CAAE,IAAA,CAAK,EAAE,CAAC;AAAA,QAAA,EAClI,IAAA,CAAK,aAAa,CAAA,6CAAA,EAAgD,UAAA,CAAW,KAAK,UAAU,CAAC,kBAAkB,cAAA,CAAe,WAAA,EAAa,aAAa,CAAC,CAAA,wCAAA,EAA2C,WAAW,IAAA,CAAK,WAAA,IAAe,KAAK,UAAU,CAAC,aAAa,EAAE;AAAA;AAAA,SAAA,CAAA,GAGtQ,EAAA;AAEJ,EAAA,OAAO,CAAA;AAAA,+BAAA,EACwB,aAAa,CAAA;AAAA,YAAA,EAChC,UAAA,CAAW,IAAI,CAAC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,OAAA,EAMrB,UAAA,CAAW,IAAA,CAAK,OAAO,CAAC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,8BAAA,EAwBD,aAAa,CAAA;AAAA,8BAAA,EACb,MAAM,CAAA;AAAA,yBAAA,EACX,SAAS,CAAA;AAAA;AAAA,wCAAA,EAEM,aAAa,CAAA;AAAA,wCAAA,EACb,MAAM,CAAA;AAAA,mCAAA,EACX,SAAS,CAAA;AAAA;AAAA;AAAA,2CAAA,EAGD,aAAa,CAAA,uCAAA,EAA0C,aAAa,CAAA,aAAA,EAAgB,QAAQ,UAAU,SAAS,CAAA;AAAA,EAC1J,IAAA,CAAK,YAAY,CAAA,mFAAA,EAAsF,UAAA,CAAW,KAAK,SAAS,CAAC,WAAW,EAAE;AAAA,4FAAA,EAClD,aAAa,2CAA2C,aAAa,CAAA;AAAA;AAAA;AAAA,iGAAA,EAGhE,MAAM,qEAAqE,MAAM,CAAA;AAAA,yBAAA,EACzJ,WAAW,uBAAuB,WAAW,CAAA;AAAA;AAAA,uBAAA,EAE/C,MAAM,0CAA0C,MAAM,CAAA;AAAA,YAAA,EACjE,SAAS;AAAA;AAAA;AAAA;AAAA,uBAAA,EAIE,MAAM,kDAAkD,MAAM,CAAA;AAAA,YAAA,EACzE,KAAK,QAAQ;AAAA;AAAA;AAAA,QAAA,EAGjB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,OAAA,CAAA;AAOrB;AAeO,SAAS,OAAA,CACd,MACA,IAAA,EACQ;AACR,EAAA,WAAA,CAAY,aAAA,EAAe,MAAM,WAAW,CAAA;AAC5C,EAAA,WAAA,CAAY,WAAA,EAAa,MAAM,SAAS,CAAA;AACxC,EAAA,eAAA,CAAgB,WAAA,EAAa,MAAM,SAAS,CAAA;AAC5C,EAAA,MAAM,SAAA,GAAY,MAAM,SAAA,IAAa,iCAAA;AACrC,EAAA,MAAM,SAAA,GAAY,MAAM,SAAA,IAAa,SAAA;AACrC,EAAA,IAAI,KAAA,GAAQ,WAAW,IAAI,CAAA;AAC3B,EAAA,MAAM,KAAK,IAAA,EAAM,QAAA;AACjB,EAAA,IAAI,EAAA,EAAI;AAGN,IAAA,MAAM,MAAA,GAAS,WAAW,EAAE,CAAA;AAC5B,IAAA,MAAM,EAAA,GAAK,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA;AAC/B,IAAA,IAAI,OAAO,EAAA,EAAI;AACb,MAAA,MAAM,MAAA,GAAS,MAAM,WAAA,IAAe,SAAA;AACpC,MAAA,KAAA,GACE,KAAA,CAAM,KAAA,CAAM,CAAA,EAAG,EAAE,IACjB,CAAA,gBAAA,EAAmB,MAAM,CAAA,qBAAA,EAAwB,MAAM,CAAA,IAAA,CAAA,GACvD,KAAA,CAAM,KAAA,CAAM,EAAA,GAAK,OAAO,MAAM,CAAA;AAAA,IAClC;AAAA,EACF;AACA,EAAA,OAAO,CAAA,uCAAA,EAA0C,SAAS,CAAA,sCAAA,EAAyC,SAAS,wBAAwB,KAAK,CAAA,KAAA,CAAA;AAC3I;AAIO,SAAS,OAAA,CAAQ,MAAc,IAAA,EAAyD;AAC7F,EAAA,WAAA,CAAY,aAAA,EAAe,KAAK,WAAW,CAAA;AAC3C,EAAA,WAAA,CAAY,SAAA,EAAW,KAAK,OAAO,CAAA;AAInC,EAAA,MAAM,SAAS,cAAA,CAAe,IAAA,CAAK,WAAA,EAAa,IAAA,CAAK,WAAW,SAAS,CAAA;AACzE,EAAA,OAAO,CAAA,6GAAA,EAAgH,MAAM,CAAA,qBAAA,EAAwB,UAAA,CAAW,IAAI,CAAC,CAAA,IAAA,CAAA;AACvK;AAUO,SAAS,OAAA,CAAQ,MAAc,IAAA,EAAuC;AAC3E,EAAA,WAAA,CAAY,aAAA,EAAe,KAAK,WAAW,CAAA;AAC3C,EAAA,OAAO,CAAA,8HAAA,EAAiI,KAAK,WAAW,CAAA;AAAA,sEAAA,EAClF,IAAI,CAAA;AAAA,UAAA,CAAA;AAE5E;AAEO,SAAS,UAAU,IAAA,EAAsB;AAC9C,EAAA,OAAO,CAAA,2DAAA,EAA8D,UAAA,CAAW,IAAI,CAAC,CAAA,IAAA,CAAA;AACvF;AAIO,SAAS,cAAc,IAAA,EAAsB;AAClD,EAAA,OAAO,8DAA8D,IAAI,CAAA,IAAA,CAAA;AAC3E;AA+CA,IAAM,kBAAA,GAAqB,WAAA;AAC3B,IAAM,iBAAA,GAAoB,UAAA;AAE1B,SAAS,WAAA,CAAY,MAAmB,SAAA,EAA2B;AACjE,EAAA,MAAM,IAAA,GAAO,UAAA,CAAW,IAAA,CAAK,IAAI,CAAA;AACjC,EAAA,IAAI,IAAA,CAAK,IAAA,KAAS,MAAA,EAAQ,OAAO,oCAAoC,IAAI,CAAA,SAAA,CAAA;AACzE,EAAA,IAAI,KAAK,IAAA,KAAS,MAAA,SAAe,CAAA,mBAAA,EAAsB,SAAS,MAAM,IAAI,CAAA,OAAA,CAAA;AAC1E,EAAA,OAAO,IAAA;AACT;AAsBO,SAAS,OAAA,CACd,CAAA,EACA,CAAA,EACA,IAAA,EACQ;AACR,EAAA,IAAI,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA,IAAK,OAAO,MAAM,QAAA,EAAU,WAAA,CAAY,QAAA,EAAU,CAAA,EAAG,MAAM,CAAA;AAI9E,EAAA,MAAM,EAAA,GAAK,cAAA;AAIX,EAAA,MAAM,SAAA,GACJ,KAAA,CAAM,OAAA,CAAQ,CAAC,KAAK,OAAO,CAAA,KAAM,QAAA,IAAY,CAAA,EAAG,MAAA,IAAU,MAAA,CAAO,CAAA,CAAE,MAAM,IACrE,iBAAA,GACA,kBAAA;AACN,EAAA,MAAM,OAAO,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA,GACxB,EAAE,GAAA,CAAI,CAAC,CAAA,KAAM,WAAA,CAAY,GAAG,SAAS,CAAC,EAAE,IAAA,CAAK,EAAE,IAK/C,CAAC,UAAA,CAAW,CAAC,CAAA,EAAG,WAAW,OAAO,CAAA,KAAM,WAAW,CAAA,GAAI,EAAE,CAAC,CAAA,CAAE,IAAA,CAAK,EAAE,CAAA,IAClE,OAAO,CAAA,EAAG,EAAE,iCAAiC,UAAA,CAAW,IAAI,CAAC,CAAA,OAAA,CAAA,GAAY,EAAA,CAAA;AAC9E,EAAA,OAAO,CAAA;AAAA;AAAA,MAAA,EAED,IAAI;AAAA;AAAA,QAAA,CAAA;AAGZ;AAIO,SAAS,GAAA,CAAI,IAAA,EAAc,KAAA,EAAe,IAAA,EAAuC;AACtF,EAAA,WAAA,CAAY,aAAA,EAAe,KAAK,WAAW,CAAA;AAI3C,EAAA,MAAM,GAAA,GAAM,WAAA,CAAY,IAAA,CAAK,WAAW,CAAA;AACxC,EAAA,OAAO,CAAA;AAAA;AAAA,mBAAA,EAEY,IAAA,CAAK,WAAW,CAAA,oBAAA,EAAuB,IAAA,CAAK,WAAW,CAAA;AAAA,iBAAA,EACzD,UAAA,CAAW,IAAI,CAAC,CAAA,qFAAA,EAAwF,GAAG,CAAA,wBAAA,EAA2B,UAAA,CAAW,KAAK,CAAC,CAAA;AAAA;AAAA;AAAA,UAAA,CAAA;AAI1K;AASO,SAAS,OAAA,CAAQ,MAAiB,IAAA,EAAyC;AAChF,EAAA,WAAA,CAAY,aAAA,EAAe,MAAM,WAAW,CAAA;AAC5C,EAAA,IAAI,IAAA,CAAK,MAAA,KAAW,CAAA,EAAG,OAAO,EAAA;AAC9B,EAAA,MAAM,SAAS,IAAA,EAAM,WAAA,GAAc,CAAA,sBAAA,EAAyB,IAAA,CAAK,WAAW,CAAA,CAAA,CAAA,GAAM,mCAAA;AAClF,EAAA,MAAM,QAAQ,IAAA,CACX,GAAA;AAAA,IACC,CAAC,CAAA,KAAM,CAAA;AAAA,+DAAA,EACoD,WAAW,CAAA,yCAAA,EAA4C,UAAA,CAAW,CAAA,CAAE,KAAK,CAAC,CAAA;AAAA,kEAAA,EACvE,UAAA,CAAW,CAAA,CAAE,KAAK,CAAC,CAAA;AAAA,WAAA;AAAA,GAEnF,CACC,KAAK,EAAE,CAAA;AACV,EAAA,OAAO,2GAA2G,MAAM,CAAA;AAAA;AAAA,yFAAA,EAE/B,KAAK,CAAA;AAAA;AAAA,UAAA,CAAA;AAGhG;AAkCO,SAAS,IAAA,CAAK,UAAkB,IAAA,EAA+C;AACpF,EAAA,OAAO,QAAA,CAAS,OAAA;AAAA,IAAQ,YAAA;AAAA,IAAc,CAAC,CAAA,EAAG,GAAA,KACxC,GAAA,IAAO,IAAA,GAAO,UAAA,CAAW,MAAA,CAAO,IAAA,CAAK,GAAG,CAAC,CAAC,CAAA,GAAI,IAAI,GAAG,CAAA,CAAA;AAAA,GACvD;AACF;AAQO,SAAS,QAAA,CAAS,UAAkB,IAAA,EAA+C;AACxF,EAAA,OAAO,QAAA,CAAS,OAAA,CAAQ,YAAA,EAAc,CAAC,GAAG,GAAA,KAAS,GAAA,IAAO,IAAA,GAAO,MAAA,CAAO,KAAK,GAAG,CAAC,CAAA,GAAI,CAAA,CAAA,EAAI,GAAG,CAAA,CAAA,CAAI,CAAA;AAClG;AAYO,SAAS,kBAAA,CAAmB,UAAkB,IAAA,EAA4E;AAC/H,EAAA,IAAI,CAACA,aAAA,CAAW,QAAQ,CAAA,EAAG,OAAO,IAAA;AAClC,EAAA,IAAI;AACF,IAAA,MAAM,OAAA,GAAUC,gBAAa,QAAQ,CAAA;AACrC,IAAA,MAAM,WAAW,QAAA,CAAS,KAAA,CAAM,GAAG,CAAA,CAAE,KAAI,IAAK,MAAA;AAC9C,IAAA,MAAM,cAAc,IAAA,EAAM,WAAA,KAAgB,SAAS,QAAA,CAAS,MAAM,IAAI,eAAA,GAAkB,WAAA,CAAA;AACxF,IAAA,OAAO,EAAE,QAAA,EAAU,OAAA,EAAS,WAAW,IAAA,EAAM,SAAA,IAAa,QAAQ,WAAA,EAAY;AAAA,EAChF,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,IAAA;AAAA,EACT;AACF","file":"index.cjs","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#fffffe' — one byte off white on purpose, so a\n * client looking for EXACTLY #ffffff does not decide the mail wants\n * inverting. Pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\n/** The CSS named colours. The full set on purpose: a guard that rejects\n * `rebeccapurple` is one consumers route around, and a routed-around guard\n * protects nothing. (F023.9 constraint.) */\nconst NAMED_COLORS = new Set(\n (\"aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue \" +\n \"blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk \" +\n \"crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki \" +\n \"darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen \" +\n \"darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue \" +\n \"dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite \" +\n \"gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki \" +\n \"lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan \" +\n \"lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen \" +\n \"lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen \" +\n \"magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen \" +\n \"mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream \" +\n \"mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid \" +\n \"palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum \" +\n \"powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown \" +\n \"seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen \" +\n \"steelblue tan teal thistle tomato transparent turquoise violet wheat white whitesmoke \" +\n \"yellow yellowgreen\").split(\" \"),\n);\n\n/** ONE muted pair for the whole package, not one per function. factBox kept an\n * `opacity:0.65` for a full card after F023.8 removed it from the footer, and\n * the acceptance criterion that should have caught it (\"no opacity on any text\n * in the shell\") passed because its test rendered renderShell and not factBox.\n * A single pair means the next primitive cannot invent a third mid-tone.\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #484848 5.18:1 */\nconst MUTED_LIGHT = \"#4a4d63\";\nconst MUTED_DARK = \"#c1c2d1\";\n\nconst HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;\nconst FUNCTIONAL = /^(?:rgb|rgba|hsl|hsla)\\(\\s*[0-9a-z.%,\\s/+-]+\\)$/i;\n\n/** Reject a brand colour that is not a colour. **REJECT, never escape** — an\n * escaped non-colour still leaves the building and still renders as literal\n * garbage inside a `style` attribute, so the customer sees a broken mail and\n * nobody sees an error. Throwing fails at the CALLER, where someone can act.\n *\n * PROVEN REACHABLE, 2026-09-03, against the built package (F023.9):\n * accentColor = '#0f7391\" onmouseover=\"alert(1)\" x=\"'\n * -> <td bgcolor=\"#0f7391\" onmouseover=\"alert(1)\" x=\"\" ...>\n * a longer payload injected a complete\n * <a href=\"https://phish.example\">Log ind her</a>\n * into the rendered mail. No script needed: a login link inside an otherwise\n * genuine, correctly-branded transactional mail IS the attack, and clients\n * that strip script still render the anchor.\n *\n * It was not reachable when this was written — a single-tenant repo passes a\n * constant from a config file and has no attacker. xrt81 now resolves branding\n * PER TENANT from a database and cardmem's template store is being built. The\n * assumption did not become false through carelessness; the deployment model\n * moved underneath it. */\nexport function assertColor(field: string, value: string | undefined): void {\n if (value === undefined) return;\n const v = value.trim();\n if (HEX.test(v) || FUNCTIONAL.test(v) || NAMED_COLORS.has(v.toLowerCase())) return;\n throw new Error(\n `@broberg/mail-core: ${field} is not a CSS colour (received ${JSON.stringify(value)}). ` +\n `Brand values are interpolated into HTML attributes, so an arbitrary string here can ` +\n `inject markup into the mail. Pass a hex, rgb()/rgba(), hsl()/hsla(), or a named colour.`,\n );\n}\n\n/** A font stack is NOT a colour and must not borrow the colour grammar — it\n * legitimately contains quotes and commas (`'Segoe UI'`). What cannot appear is\n * a tag delimiter or a quote that closes the attribute we sit inside. */\nexport function assertFontStack(field: string, value: string | undefined): void {\n if (value === undefined) return;\n if (!/[<>\"`]/.test(value)) return;\n throw new Error(\n `@broberg/mail-core: ${field} contains a character that can break out of the ` +\n `attribute it is rendered into (received ${JSON.stringify(value)}). ` +\n `Use single quotes for family names: \"-apple-system,'Segoe UI',sans-serif\".`,\n );\n}\n\n// ── contrast ────────────────────────────────────────────────────────────────\n//\n// F023.13. One `accentColor` was doing two jobs — a SURFACE (the top bar, the\n// cta background, a border) and TEXT (the eyebrow, the footer link) — and a\n// brand that works as one is usually illegal as the other.\n//\n// MEASURED on WebHouse gold #F7BB2E, reported by cms and recomputed here:\n//\n// accent as TEXT on white 1.74:1\n// accent as TEXT on the footer's #f4f4f5 1.58:1\n// WHITE label on the accent surface 1.74:1 ← was hardcoded\n// dark label on the accent surface 12.10:1\n//\n// WCAG AA wants 4.5:1. And a FIXED label colour cannot be right: on #0f7391\n// white is correct (5.41) and dark is not (3.22); on gold it is the exact\n// reverse. Only this file sees both sides of the pair, so this file has to pick.\n\n/** WCAG relative luminance. NOT the BT.601 perceived brightness `isDark` uses:\n * that answers \"does this look dark\", which is a different question and gets\n * the boundary wrong. Measured — #808080: isDark says false, so a\n * brightness-based pick would choose WHITE at 3.95:1 over dark at 4.41:1, i.e.\n * the worse of the two. */\nfunction relativeLuminance(r: number, g: number, b: number): number {\n const lin = (c: number) => {\n const v = c / 255;\n return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);\n };\n return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n}\n\n/** #rgb / #rrggbb → [r,g,b], or null for anything else. Deliberately narrow: a\n * functional colour (rgb()/hsl()) or a named one is left ALONE rather than\n * half-parsed, because a wrong contrast decision is worse than no decision. */\nfunction parseHex(value: string): [number, number, number] | null {\n const v = value.trim();\n const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v);\n if (!m) return null;\n const h = m[1]!.length === 3 ? m[1]!.split(\"\").map((c) => c + c).join(\"\") : m[1]!;\n const n = parseInt(h, 16);\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\n/** WCAG contrast ratio between two colours, or null if either is not a hex we\n * parse. Exported so a consumer can assert their own brand before shipping it\n * — the check cms had to write by hand. */\nexport function contrastRatio(a: string, b: string): number | null {\n const x = parseHex(a), y = parseHex(b);\n if (!x || !y) return null;\n const la = relativeLuminance(...x), lb = relativeLuminance(...y);\n return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);\n}\n\n/** The ink to print ON a coloured surface: whichever of the shell's two inks\n * contrasts MORE. Not a threshold — a choice between the only two we have, so\n * it always returns the better one even where neither reaches 4.5:1. */\nexport function readableInk(surface: string): string {\n const dark = contrastRatio(\"#1a1a1a\", surface);\n const light = contrastRatio(\"#ffffff\", surface);\n if (dark === null || light === null) return \"#ffffff\"; // unparseable: today's behaviour\n return dark > light ? \"#1a1a1a\" : \"#ffffff\";\n}\n\n/** An accent used AS TEXT, adjusted until it is legible on `surface` —\n * **and returned UNCHANGED when it already is.** That last clause is what\n * keeps every existing consumer's mail byte-identical: #0f7391 measures 4.92:1\n * on #f4f4f5 and comes back untouched.\n *\n * Moves AWAY from the surface's luminance, so it darkens on a light background\n * and LIGHTENS on a dark one. A \"darken until legible\" helper would be right\n * for cms and wrong for our own dark shell, where the footer link sits on\n * #101010 — measured at 3.52:1 with our own default teal, i.e. already failing\n * before this card existed.\n *\n * Scales all three channels by one factor, which preserves hue and saturation\n * exactly and only moves brightness: the brand stays recognisably the brand. */\nexport function readableAccent(accent: string, surface: string): string {\n const current = contrastRatio(accent, surface);\n if (current === null) return accent; // not a hex we parse — leave it alone\n if (current >= 4.5) return accent; // already legible: DO NOT TOUCH\n\n const rgb = parseHex(accent)!;\n const surf = parseHex(surface)!;\n const goDarker = relativeLuminance(...surf) > 0.5;\n const hex = (c: [number, number, number]) =>\n \"#\" + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, \"0\")).join(\"\");\n\n // 40 steps of 2.5%: fine enough that the result is still visibly the brand,\n // bounded so this can never loop. If even the endpoint fails we return the\n // endpoint — the most legible value available beats silently giving up.\n let best = accent, bestRatio = current;\n for (let i = 1; i <= 40; i++) {\n const t = i / 40;\n const candidate: [number, number, number] = goDarker\n ? [rgb[0] * (1 - t), rgb[1] * (1 - t), rgb[2] * (1 - t)]\n : [rgb[0] + (255 - rgb[0]) * t, rgb[1] + (255 - rgb[1]) * t, rgb[2] + (255 - rgb[2]) * t];\n const h = hex(candidate);\n const r = contrastRatio(h, surface)!;\n if (r > bestRatio) { best = h; bestRatio = r; }\n if (r >= 4.5) return h;\n }\n return best;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n // Driven from the FIELD NAMES rather than a hand-written list of call sites:\n // a list of seven line numbers goes stale the next time this file is edited,\n // and staleness here reads as coverage. (F023.9 AC#2.)\n assertColor(\"accentColor\", b.accentColor);\n assertColor(\"cardBg\", b.cardBg);\n assertColor(\"textColor\", b.textColor);\n assertColor(\"backdropColor\", b.backdropColor);\n assertFontStack(\"fontSans\", b.fontSans);\n assertFontStack(\"fontSerif\", b.fontSerif);\n\n // #fffffe, not #ffffff, and the one-off byte is the whole point: several\n // clients treat EXACTLY white as \"this is a light mail, invert it\". One step\n // off slips that recognition and no eye can tell the difference. Measured at\n // ZERO effect in Outlook iOS specifically (F023.7) — it is on the list because\n // it works in OTHER clients, not because it rescues that one.\n const cardBg = b.cardBg ?? \"#fffffe\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** How wide to DRAW the logo, in px. Omit and you get the historic centred\n * slot unchanged (`max-width:180px`, no width attribute) — byte-identical to\n * every mail sent before this field existed.\n *\n * SET IT IF YOU CAN, and set it even when 180 is what you want: a supplied\n * width is emitted as an HTML `width` ATTRIBUTE as well as in the style, and\n * **the attribute is the only half Outlook reads.** Outlook's Word engine\n * ignores CSS dimensions on an image, so without the attribute it draws the\n * mark at its full FILE size.\n *\n * WHICH IS WHY THIS EXISTS: vn-leker shipped a 480×480 mark — 2× for a 40px\n * logo, the correct decision — and the shell drew it 180px wide on a 520px\n * card. Christian opened it in Gmail: «Alt for stort logo». **The better the\n * source you supply, the worse the result**; a 96px file would have looked\n * fine. The careful consumer is the one this hits.\n *\n * No `height` attribute is emitted, deliberately: this package serves\n * non-square logos, and a forced square distorts them in exactly the client\n * that honours attributes. */\n logoWidth?: number;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"3\";\n// 3 (F023.13): where the accent was used as TEXT — the cta label, the eyebrow,\n// the footer link — the colour is now DERIVED for contrast. A brand that was\n// already legible renders byte-identically (proven against the published 0.7.0\n// across seven shapes); a light brand changes, which is the fix.\n\n/** Warn ONCE per process that a logo is being drawn without an explicit width.\n *\n * vn-leker's proposal, and it is better than either option I had. The problem:\n * omitting `logoWidth` leaves the mark drawn at its full FILE size in Outlook,\n * and we could not tell how many consumers that affects because only the\n * consuming repos know their asset widths. Changing the default would fix it\n * for everyone and silently change one client's rendering for every existing\n * consumer — breaking, and not ours to decide.\n *\n * A warning changes no mail and makes each consumer discover their OWN\n * exposure the next time they run their suite. So the count arrives from\n * measurement instead of from a guess, and a future default change is\n * something everyone has already seen coming.\n *\n * ONCE PER PROCESS, not per render: a transactional mailer renders in a loop,\n * and a warning printed a thousand times is one nobody reads.\n *\n * KNOWN DATA POINT: vn-leker's mark is 480x480 — 2x for a 40px logo, the\n * correct decision by their supplier — drawn at 56px. Broken in Outlook without\n * logoWidth. That is one confirmed YES; the rest of the fleet is unmeasured. */\nlet warnedUnsizedLogo = false;\nfunction warnUnsizedLogo(): void {\n if (warnedUnsizedLogo) return;\n warnedUnsizedLogo = true;\n // eslint-disable-next-line no-console\n console.warn(\n \"@broberg/mail-core: rendering a logo without `logoWidth`. Outlook ignores CSS \" +\n \"dimensions on an image, so it will draw your file at its FULL width there — \" +\n \"a 480px source becomes a 480px logo. Pass logoWidth (e.g. { logoWidth: 56 }) \" +\n \"even if 180 is what you want. This warns once per process.\",\n );\n}\n\n/** Test seam: reset the once-per-process warning. Exported because a test that\n * cannot re-arm the warning can only ever assert it fires the FIRST time, which\n * proves the flag exists rather than that the condition is right. */\nexport function __resetLogoWarning(): void {\n warnedUnsizedLogo = false;\n}\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n // A supplied width is emitted as an ATTRIBUTE as well as in the style, because\n // the attribute is the half Outlook reads. Omitted keeps the historic block\n // byte-for-byte — existing production mail must not shift under consumers who\n // never asked for anything.\n //\n // KNOWN AND DELIBERATE: the DEFAULT therefore stays Outlook-unsafe. A caller\n // who omits logoWidth still gets a mark drawn at its full file size in\n // Outlook. Making 180 emit an attribute would fix that for everyone and would\n // change what every existing consumer's mail looks like in one client, which\n // is not a change to make silently. Set logoWidth explicitly.\n if (logoSrc && opts.logoWidth === undefined) warnUnsizedLogo();\n const logoW =\n typeof opts.logoWidth === \"number\" && Number.isFinite(opts.logoWidth) && opts.logoWidth > 0\n ? Math.round(opts.logoWidth)\n : null;\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\"${logoW ? ` width=\"${logoW}\"` : \"\"} style=\"display:block;margin:0 auto;${logoW ? `width:${logoW}px` : \"max-width:180px\"};height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n // The footer zone is carried by a COLOURED RULE, not by its fill. fd-sundhed\n // measured card and footer BOTH becoming #484848 in Outlook iOS — the fill\n // stopped distinguishing anything and the zone ceased to exist. What survived\n // was a rule in the brand's own accent. The previous rgba(0,0,0,0.08) is a\n // near-invisible black alpha, i.e. exactly the thing that disappears there.\n //\n // And the text is a real COLOUR, never an opacity. An opacity is not a low\n // contrast value — it is a contrast value FOR ONE BACKGROUND: opacity 0.65 of\n // #1a1c2b measures 5.29:1 while the ground stays white, and lands somewhere\n // nobody measured the moment a client tints or inverts. No contrast tool can\n // read it, because there is no colour there to read.\n // #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #1a1c2b 9.56:1\n // #4a4d63 on #ffffff 8.29:1 #c1c2d1 on #484848 5.18:1 (the mapped case)\n const footerText = isDark(backdropColor) ? MUTED_DARK : MUTED_LIGHT;\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;color:${footerText};\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${readableAccent(accentColor, backdropColor)};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\"> </td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n assertColor(\"accentColor\", opts?.accentColor);\n assertColor(\"textColor\", opts?.textColor);\n assertFontStack(\"fontSerif\", opts?.fontSerif);\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string; surface?: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n assertColor(\"surface\", opts.surface);\n // The eyebrow sits on the CARD, so that is what it is measured against —\n // #fffffe by default, matching resolveColors. `surface` exists for a caller\n // who renders it somewhere else; it is not a colour override.\n const colour = readableAccent(opts.accentColor, opts.surface ?? \"#fffffe\");\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${colour};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\n/** One line of a signature, and the tier that styles it.\n *\n * THE INVARIANT, and it is testable rather than a matter of taste: **each tier\n * changes exactly ONE axis against `lead`.** There is no fourth tier waiting,\n * because there is no fourth axis left to spend.\n *\n * lead the base — the size and colour of the surrounding text\n * name + bold (same size, same colour)\n * meta + muted colour (same size, same weight)\n *\n * WHY `name` IS NOT ALSO DARKER, though the obvious signature makes it so:\n * measured on vn-leker's own palette, #1a1c2b is 16.86:1 on white and #0b0e15\n * is 19.29:1. Both are so far past every threshold that the step cannot be\n * seen. The weight does all the work; the colour shift was decoration. Their\n * finding, on their own design.\n *\n * WHY `meta` HAS NO SIZE OF ITS OWN, which is the tempting third axis: a tier\n * carrying a *relative* size step turns a 17/17-bold/15 signature into\n * 15/15-bold/13 in a palette with a smaller base — and 13px secondary text is\n * the exact thing fd-sundhed measured their way out of (13.5px #8486a6 at\n * 3.5:1, failing WCAG in LIGHT mode, before anyone mentioned dark). They went\n * UP in size as part of what doubled legibility. A relative step would quietly\n * roll that back, and the fault would live in a tier definition nobody reads\n * while choosing `meta`. 15px is a measured floor for secondary text in mail.\n */\nexport interface SignOffLine {\n text: string;\n tier?: \"lead\" | \"name\" | \"meta\";\n}\n\n/** The muted tier's colour, one value per background polarity — never an\n * `opacity`, for the reason spelled out on the footer above: an opacity is a\n * contrast value for ONE background only.\n *\n * BOTH POLARITIES EXIST BECAUSE THE SHELL SUPPORTS DARK CARDS, and the first\n * cut of this function did not: a hardcoded #4a4d63 measures **2.10:1** on a\n * #1a1a1a card — far under the 4.5:1 floor, while the README advertises dark\n * cards as a supported mode. That is the same defect this change removed from\n * the footer, reintroduced one function away in the same commit. Found by\n * reviewing the diff, not by any test — which is why the test now renders BOTH\n * polarities and asserts they DIFFER.\n *\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #1a1a1a 2.10:1 <- #c1c2d1 on #484848 5.18:1\n */\nconst SIGNOFF_META_LIGHT = MUTED_LIGHT;\nconst SIGNOFF_META_DARK = MUTED_DARK;\n\nfunction signOffLine(line: SignOffLine, metaColor: string): string {\n const text = escapeHtml(line.text);\n if (line.tier === \"name\") return `<strong style=\"font-weight:700;\">${text}</strong>`;\n if (line.tier === \"meta\") return `<span style=\"color:${metaColor};\">${text}</span>`;\n return text;\n}\n\n/** A signature block.\n *\n * TWO FORMS, and the old one is load-bearing: three repos call\n * `signOff(line1, line2, sign)` in production mail, so it renders\n * byte-identically and always will.\n *\n * THE OLD FORM'S DEFECT, which is why the array form exists: its big slot is\n * the LAST argument and its only axis is size. A name-then-title signature had\n * to be forced into it, and rendered the job title larger than the person —\n * in a mail Christian opened. The API could not express the signature, so the\n * mapping was wrong before anyone wrote a line of calling code.\n *\n * An index-based fix (`{ emphasizeIndex }`) was proposed and rejected: it\n * would place the name and still leave the title nowhere to go, i.e. the same\n * defect in a new shape. It also defaults to index 0 — \"Med venlig hilsen\" —\n * inverting the old form's last-line emphasis for everyone who did not pass\n * the option. vn-leker caught that; it was worse than the bug it fixed.\n */\nexport function signOff(lines: SignOffLine[], opts?: { cardBg?: string }): string;\nexport function signOff(line1: string, line2: string, sign: string): string;\nexport function signOff(\n a: SignOffLine[] | string,\n b?: { cardBg?: string } | string,\n sign?: string,\n): string {\n if (Array.isArray(a) && typeof b === \"object\") assertColor(\"cardBg\", b?.cardBg);\n // The separator carries the original's indentation, so the legacy form is\n // byte-identical rather than merely equivalent. A test asserts that against a\n // stored snapshot; reading it here is not the proof.\n const br = \"<br>\\n \";\n // `meta` follows the card it sits on, using the SAME isDark() the shell uses,\n // so the two cannot drift apart. A caller who omits cardBg gets the light\n // pair, which is exactly what the shell's own default card is.\n const metaColor =\n Array.isArray(a) && typeof b === \"object\" && b?.cardBg && isDark(b.cardBg)\n ? SIGNOFF_META_DARK\n : SIGNOFF_META_LIGHT;\n const body = Array.isArray(a)\n ? a.map((l) => signOffLine(l, metaColor)).join(br)\n // The legacy form — with ONE correction: an empty `sign` used to emit a\n // trailing `<br>` plus `<span style=\"font-size:20px;\"></span>`, i.e. a blank\n // line and an empty styled element that failed nowhere and so survived.\n // vn-leker's own signature replacement left exactly that residue.\n : [escapeHtml(a), escapeHtml(typeof b === \"string\" ? b : \"\")].join(br) +\n (sign ? `${br}<span style=\"font-size:20px;\">${escapeHtml(sign)}</span>` : \"\");\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${body}\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n // The BACKGROUND keeps the brand colour exactly; only the LABEL is derived.\n // That is the half cms could not fix from outside: their workaround had to\n // darken the button itself, so the button stopped being WebHouse gold.\n const ink = readableInk(opts.accentColor);\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:${ink};text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n assertColor(\"accentColor\", opts?.accentColor);\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;color:${MUTED_LIGHT};white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace `{token}` placeholders with values. **Every value is HTML-escaped.**\n * Unknown tokens are left as-is.\n *\n * ⚠️ THE ESCAPING IS THE POINT, and it was missing until 0.6.0. `vars` is\n * dynamic BY DEFINITION — a customer's name, a booking reference, a message\n * someone typed — so every value reaching this function is exactly the class of\n * data that must be escaped. Measured on 0.5.0 and earlier:\n *\n * fill(\"<p>Hej {name}</p>\", { name: '<a href=\"https://phish.example\">Log ind</a>' })\n * -> <p>Hej <a href=\"https://phish.example\">Log ind</a></p>\n *\n * The anchor was in the mail. If you were on an earlier version and passed\n * anything user-supplied through this, assume it rendered as markup.\n *\n * Composing actual markup? Use {@link fillHtml}, whose NAME says so at the call\n * site. There is deliberately no escaping flag: a flag has to default to\n * something, and the wrong default is invisible where it is called.\n *\n * ⚠️ **ORDER MATTERS NOW THAT THIS ESCAPES — RENDER FIRST, THEN FILL.**\n * Filed by cardmem the day the escaping landed, measured in their own store:\n *\n * render THEN fill \"Sørensen & Søn\" -> \"Sørensen & Søn\" ✓\n * fill THEN render \"Sørensen & Søn\" -> \"Sørensen &amp; Søn\" ✗\n *\n * Render first and `{token}` is ordinary text that survives escaping untouched,\n * so each value is escaped exactly once — by the function that substitutes it.\n *\n * It fails in the worst available direction: perfect for every customer whose\n * name has no `&`, `<` or quote, which is most of them. It reaches production\n * looking correct and breaks on one real person, in their inbox, where nobody\n * is watching. If you call both, compose them in ONE function so a call site\n * cannot get the order wrong. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) =>\n key in vars ? escapeHtml(String(vars[key])) : `{${key}}`,\n );\n}\n\n/** Like {@link fill}, but the values are injected as **raw HTML** — nothing is\n * escaped, and the caller owns every value.\n *\n * Mirrors `paragraph` / `paragraphHtml` above: the unsafe one is the one you\n * have to name. Reach for it only when the value is markup you built yourself,\n * never for anything that reached you from a user, a database or a request. */\nexport function fillHtml(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -52,6 +52,28 @@ declare function assertColor(field: string, value: string | undefined): void;
|
|
|
52
52
|
* legitimately contains quotes and commas (`'Segoe UI'`). What cannot appear is
|
|
53
53
|
* a tag delimiter or a quote that closes the attribute we sit inside. */
|
|
54
54
|
declare function assertFontStack(field: string, value: string | undefined): void;
|
|
55
|
+
/** WCAG contrast ratio between two colours, or null if either is not a hex we
|
|
56
|
+
* parse. Exported so a consumer can assert their own brand before shipping it
|
|
57
|
+
* — the check cms had to write by hand. */
|
|
58
|
+
declare function contrastRatio(a: string, b: string): number | null;
|
|
59
|
+
/** The ink to print ON a coloured surface: whichever of the shell's two inks
|
|
60
|
+
* contrasts MORE. Not a threshold — a choice between the only two we have, so
|
|
61
|
+
* it always returns the better one even where neither reaches 4.5:1. */
|
|
62
|
+
declare function readableInk(surface: string): string;
|
|
63
|
+
/** An accent used AS TEXT, adjusted until it is legible on `surface` —
|
|
64
|
+
* **and returned UNCHANGED when it already is.** That last clause is what
|
|
65
|
+
* keeps every existing consumer's mail byte-identical: #0f7391 measures 4.92:1
|
|
66
|
+
* on #f4f4f5 and comes back untouched.
|
|
67
|
+
*
|
|
68
|
+
* Moves AWAY from the surface's luminance, so it darkens on a light background
|
|
69
|
+
* and LIGHTENS on a dark one. A "darken until legible" helper would be right
|
|
70
|
+
* for cms and wrong for our own dark shell, where the footer link sits on
|
|
71
|
+
* #101010 — measured at 3.52:1 with our own default teal, i.e. already failing
|
|
72
|
+
* before this card existed.
|
|
73
|
+
*
|
|
74
|
+
* Scales all three channels by one factor, which preserves hue and saturation
|
|
75
|
+
* exactly and only moves brightness: the brand stays recognisably the brand. */
|
|
76
|
+
declare function readableAccent(accent: string, surface: string): string;
|
|
55
77
|
interface ShellOpts extends BrandColors {
|
|
56
78
|
subject: string;
|
|
57
79
|
/** Hidden preview text shown in the mail-client inbox list. */
|
|
@@ -136,7 +158,11 @@ declare function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: stri
|
|
|
136
158
|
* An HTML COMMENT rather than an attribute: comments survive every client we
|
|
137
159
|
* have measured, and an attribute on <html> is one of the first things a
|
|
138
160
|
* sanitising webmail rewrites. */
|
|
139
|
-
declare const SHELL_VERSION = "
|
|
161
|
+
declare const SHELL_VERSION = "3";
|
|
162
|
+
/** Test seam: reset the once-per-process warning. Exported because a test that
|
|
163
|
+
* cannot re-arm the warning can only ever assert it fires the FIRST time, which
|
|
164
|
+
* proves the flag exists rather than that the condition is right. */
|
|
165
|
+
declare function __resetLogoWarning(): void;
|
|
140
166
|
declare function renderShell(opts: ShellOpts): string;
|
|
141
167
|
/** `emphasis` italicises the FIRST occurrence of that substring in the accent
|
|
142
168
|
* colour — the "one word picked out of the headline" brand signature three
|
|
@@ -161,6 +187,7 @@ declare function heading(text: string, opts?: {
|
|
|
161
187
|
* and in the accent colour; a recurring component in every surveyed template. */
|
|
162
188
|
declare function eyebrow(text: string, opts: {
|
|
163
189
|
accentColor: string;
|
|
190
|
+
surface?: string;
|
|
164
191
|
}): string;
|
|
165
192
|
/** Free prose with a coloured left rule — a NOTE, not a table.
|
|
166
193
|
*
|
|
@@ -296,4 +323,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
|
|
|
296
323
|
contentType?: string;
|
|
297
324
|
}): MailAttachment | null;
|
|
298
325
|
|
|
299
|
-
export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, assertColor, assertFontStack, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, fillHtml, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
|
326
|
+
export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, __resetLogoWarning, assertColor, assertFontStack, contrastRatio, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, fillHtml, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, readableAccent, readableInk, renderShell, resolveLogoSrc, signOff };
|
package/dist/index.d.ts
CHANGED
|
@@ -52,6 +52,28 @@ declare function assertColor(field: string, value: string | undefined): void;
|
|
|
52
52
|
* legitimately contains quotes and commas (`'Segoe UI'`). What cannot appear is
|
|
53
53
|
* a tag delimiter or a quote that closes the attribute we sit inside. */
|
|
54
54
|
declare function assertFontStack(field: string, value: string | undefined): void;
|
|
55
|
+
/** WCAG contrast ratio between two colours, or null if either is not a hex we
|
|
56
|
+
* parse. Exported so a consumer can assert their own brand before shipping it
|
|
57
|
+
* — the check cms had to write by hand. */
|
|
58
|
+
declare function contrastRatio(a: string, b: string): number | null;
|
|
59
|
+
/** The ink to print ON a coloured surface: whichever of the shell's two inks
|
|
60
|
+
* contrasts MORE. Not a threshold — a choice between the only two we have, so
|
|
61
|
+
* it always returns the better one even where neither reaches 4.5:1. */
|
|
62
|
+
declare function readableInk(surface: string): string;
|
|
63
|
+
/** An accent used AS TEXT, adjusted until it is legible on `surface` —
|
|
64
|
+
* **and returned UNCHANGED when it already is.** That last clause is what
|
|
65
|
+
* keeps every existing consumer's mail byte-identical: #0f7391 measures 4.92:1
|
|
66
|
+
* on #f4f4f5 and comes back untouched.
|
|
67
|
+
*
|
|
68
|
+
* Moves AWAY from the surface's luminance, so it darkens on a light background
|
|
69
|
+
* and LIGHTENS on a dark one. A "darken until legible" helper would be right
|
|
70
|
+
* for cms and wrong for our own dark shell, where the footer link sits on
|
|
71
|
+
* #101010 — measured at 3.52:1 with our own default teal, i.e. already failing
|
|
72
|
+
* before this card existed.
|
|
73
|
+
*
|
|
74
|
+
* Scales all three channels by one factor, which preserves hue and saturation
|
|
75
|
+
* exactly and only moves brightness: the brand stays recognisably the brand. */
|
|
76
|
+
declare function readableAccent(accent: string, surface: string): string;
|
|
55
77
|
interface ShellOpts extends BrandColors {
|
|
56
78
|
subject: string;
|
|
57
79
|
/** Hidden preview text shown in the mail-client inbox list. */
|
|
@@ -136,7 +158,11 @@ declare function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: stri
|
|
|
136
158
|
* An HTML COMMENT rather than an attribute: comments survive every client we
|
|
137
159
|
* have measured, and an attribute on <html> is one of the first things a
|
|
138
160
|
* sanitising webmail rewrites. */
|
|
139
|
-
declare const SHELL_VERSION = "
|
|
161
|
+
declare const SHELL_VERSION = "3";
|
|
162
|
+
/** Test seam: reset the once-per-process warning. Exported because a test that
|
|
163
|
+
* cannot re-arm the warning can only ever assert it fires the FIRST time, which
|
|
164
|
+
* proves the flag exists rather than that the condition is right. */
|
|
165
|
+
declare function __resetLogoWarning(): void;
|
|
140
166
|
declare function renderShell(opts: ShellOpts): string;
|
|
141
167
|
/** `emphasis` italicises the FIRST occurrence of that substring in the accent
|
|
142
168
|
* colour — the "one word picked out of the headline" brand signature three
|
|
@@ -161,6 +187,7 @@ declare function heading(text: string, opts?: {
|
|
|
161
187
|
* and in the accent colour; a recurring component in every surveyed template. */
|
|
162
188
|
declare function eyebrow(text: string, opts: {
|
|
163
189
|
accentColor: string;
|
|
190
|
+
surface?: string;
|
|
164
191
|
}): string;
|
|
165
192
|
/** Free prose with a coloured left rule — a NOTE, not a table.
|
|
166
193
|
*
|
|
@@ -296,4 +323,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
|
|
|
296
323
|
contentType?: string;
|
|
297
324
|
}): MailAttachment | null;
|
|
298
325
|
|
|
299
|
-
export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, assertColor, assertFontStack, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, fillHtml, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
|
326
|
+
export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, __resetLogoWarning, assertColor, assertFontStack, contrastRatio, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, fillHtml, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, readableAccent, readableInk, renderShell, resolveLogoSrc, signOff };
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,55 @@ function assertFontStack(field, value) {
|
|
|
29
29
|
`@broberg/mail-core: ${field} contains a character that can break out of the attribute it is rendered into (received ${JSON.stringify(value)}). Use single quotes for family names: "-apple-system,'Segoe UI',sans-serif".`
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
|
+
function relativeLuminance(r, g, b) {
|
|
33
|
+
const lin = (c) => {
|
|
34
|
+
const v = c / 255;
|
|
35
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
36
|
+
};
|
|
37
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
38
|
+
}
|
|
39
|
+
function parseHex(value) {
|
|
40
|
+
const v = value.trim();
|
|
41
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v);
|
|
42
|
+
if (!m) return null;
|
|
43
|
+
const h = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
|
|
44
|
+
const n = parseInt(h, 16);
|
|
45
|
+
return [n >> 16 & 255, n >> 8 & 255, n & 255];
|
|
46
|
+
}
|
|
47
|
+
function contrastRatio(a, b) {
|
|
48
|
+
const x = parseHex(a), y = parseHex(b);
|
|
49
|
+
if (!x || !y) return null;
|
|
50
|
+
const la = relativeLuminance(...x), lb = relativeLuminance(...y);
|
|
51
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
52
|
+
}
|
|
53
|
+
function readableInk(surface) {
|
|
54
|
+
const dark = contrastRatio("#1a1a1a", surface);
|
|
55
|
+
const light = contrastRatio("#ffffff", surface);
|
|
56
|
+
if (dark === null || light === null) return "#ffffff";
|
|
57
|
+
return dark > light ? "#1a1a1a" : "#ffffff";
|
|
58
|
+
}
|
|
59
|
+
function readableAccent(accent, surface) {
|
|
60
|
+
const current = contrastRatio(accent, surface);
|
|
61
|
+
if (current === null) return accent;
|
|
62
|
+
if (current >= 4.5) return accent;
|
|
63
|
+
const rgb = parseHex(accent);
|
|
64
|
+
const surf = parseHex(surface);
|
|
65
|
+
const goDarker = relativeLuminance(...surf) > 0.5;
|
|
66
|
+
const hex = (c) => "#" + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join("");
|
|
67
|
+
let best = accent, bestRatio = current;
|
|
68
|
+
for (let i = 1; i <= 40; i++) {
|
|
69
|
+
const t = i / 40;
|
|
70
|
+
const candidate = goDarker ? [rgb[0] * (1 - t), rgb[1] * (1 - t), rgb[2] * (1 - t)] : [rgb[0] + (255 - rgb[0]) * t, rgb[1] + (255 - rgb[1]) * t, rgb[2] + (255 - rgb[2]) * t];
|
|
71
|
+
const h = hex(candidate);
|
|
72
|
+
const r = contrastRatio(h, surface);
|
|
73
|
+
if (r > bestRatio) {
|
|
74
|
+
best = h;
|
|
75
|
+
bestRatio = r;
|
|
76
|
+
}
|
|
77
|
+
if (r >= 4.5) return h;
|
|
78
|
+
}
|
|
79
|
+
return best;
|
|
80
|
+
}
|
|
32
81
|
function isDark(hex) {
|
|
33
82
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
34
83
|
if (!m) return false;
|
|
@@ -58,13 +107,25 @@ function resolveLogoSrc(logo, fallbackUrl) {
|
|
|
58
107
|
if (/^data:/i.test(url)) return null;
|
|
59
108
|
return url;
|
|
60
109
|
}
|
|
61
|
-
var SHELL_VERSION = "
|
|
110
|
+
var SHELL_VERSION = "3";
|
|
111
|
+
var warnedUnsizedLogo = false;
|
|
112
|
+
function warnUnsizedLogo() {
|
|
113
|
+
if (warnedUnsizedLogo) return;
|
|
114
|
+
warnedUnsizedLogo = true;
|
|
115
|
+
console.warn(
|
|
116
|
+
"@broberg/mail-core: rendering a logo without `logoWidth`. Outlook ignores CSS dimensions on an image, so it will draw your file at its FULL width there \u2014 a 480px source becomes a 480px logo. Pass logoWidth (e.g. { logoWidth: 56 }) even if 180 is what you want. This warns once per process."
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
function __resetLogoWarning() {
|
|
120
|
+
warnedUnsizedLogo = false;
|
|
121
|
+
}
|
|
62
122
|
function renderShell(opts) {
|
|
63
123
|
const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);
|
|
64
124
|
const lang = opts.lang ?? "en";
|
|
65
125
|
const showFooter = opts.showFooter ?? true;
|
|
66
126
|
const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);
|
|
67
127
|
const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? "";
|
|
128
|
+
if (logoSrc && opts.logoWidth === void 0) warnUnsizedLogo();
|
|
68
129
|
const logoW = typeof opts.logoWidth === "number" && Number.isFinite(opts.logoWidth) && opts.logoWidth > 0 ? Math.round(opts.logoWidth) : null;
|
|
69
130
|
const logoBlock = logoSrc ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto 16px;">
|
|
70
131
|
<tr><td>
|
|
@@ -75,7 +136,7 @@ function renderShell(opts) {
|
|
|
75
136
|
const footerBlock = showFooter ? `<tr>
|
|
76
137
|
<td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};">
|
|
77
138
|
${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;color:${footerText};">${escapeHtml(l)}</p>`).join("")}
|
|
78
|
-
${opts.footerHref ? `<p style="margin:0;font-size:11px;"><a href="${escapeAttr(opts.footerHref)}" style="color:${accentColor};text-decoration:none;font-weight:600;">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : ""}
|
|
139
|
+
${opts.footerHref ? `<p style="margin:0;font-size:11px;"><a href="${escapeAttr(opts.footerHref)}" style="color:${readableAccent(accentColor, backdropColor)};text-decoration:none;font-weight:600;">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : ""}
|
|
79
140
|
</td>
|
|
80
141
|
</tr>` : "";
|
|
81
142
|
return `<!doctype html>
|
|
@@ -164,7 +225,9 @@ function heading(text, opts) {
|
|
|
164
225
|
}
|
|
165
226
|
function eyebrow(text, opts) {
|
|
166
227
|
assertColor("accentColor", opts.accentColor);
|
|
167
|
-
|
|
228
|
+
assertColor("surface", opts.surface);
|
|
229
|
+
const colour = readableAccent(opts.accentColor, opts.surface ?? "#fffffe");
|
|
230
|
+
return `<p style="margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${colour};text-align:center;">${escapeHtml(text)}</p>`;
|
|
168
231
|
}
|
|
169
232
|
function noteBox(html, opts) {
|
|
170
233
|
assertColor("accentColor", opts.accentColor);
|
|
@@ -199,10 +262,11 @@ function signOff(a, b, sign) {
|
|
|
199
262
|
}
|
|
200
263
|
function cta(href, label, opts) {
|
|
201
264
|
assertColor("accentColor", opts.accentColor);
|
|
265
|
+
const ink = readableInk(opts.accentColor);
|
|
202
266
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:28px auto 8px;">
|
|
203
267
|
<tr>
|
|
204
268
|
<td bgcolor="${opts.accentColor}" style="background:${opts.accentColor};border-radius:999px;">
|
|
205
|
-
<a href="${escapeAttr(href)}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color
|
|
269
|
+
<a href="${escapeAttr(href)}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:${ink};text-decoration:none;">${escapeHtml(label)}</a>
|
|
206
270
|
</td>
|
|
207
271
|
</tr>
|
|
208
272
|
</table>`;
|
|
@@ -244,6 +308,6 @@ function makeLogoAttachment(filePath, opts) {
|
|
|
244
308
|
}
|
|
245
309
|
}
|
|
246
310
|
|
|
247
|
-
export { SHELL_VERSION, assertColor, assertFontStack, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, fillHtml, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
|
311
|
+
export { SHELL_VERSION, __resetLogoWarning, assertColor, assertFontStack, contrastRatio, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, fillHtml, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, readableAccent, readableInk, renderShell, resolveLogoSrc, signOff };
|
|
248
312
|
//# sourceMappingURL=index.js.map
|
|
249
313
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#fffffe' — one byte off white on purpose, so a\n * client looking for EXACTLY #ffffff does not decide the mail wants\n * inverting. Pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\n/** The CSS named colours. The full set on purpose: a guard that rejects\n * `rebeccapurple` is one consumers route around, and a routed-around guard\n * protects nothing. (F023.9 constraint.) */\nconst NAMED_COLORS = new Set(\n (\"aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue \" +\n \"blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk \" +\n \"crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki \" +\n \"darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen \" +\n \"darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue \" +\n \"dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite \" +\n \"gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki \" +\n \"lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan \" +\n \"lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen \" +\n \"lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen \" +\n \"magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen \" +\n \"mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream \" +\n \"mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid \" +\n \"palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum \" +\n \"powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown \" +\n \"seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen \" +\n \"steelblue tan teal thistle tomato transparent turquoise violet wheat white whitesmoke \" +\n \"yellow yellowgreen\").split(\" \"),\n);\n\n/** ONE muted pair for the whole package, not one per function. factBox kept an\n * `opacity:0.65` for a full card after F023.8 removed it from the footer, and\n * the acceptance criterion that should have caught it (\"no opacity on any text\n * in the shell\") passed because its test rendered renderShell and not factBox.\n * A single pair means the next primitive cannot invent a third mid-tone.\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #484848 5.18:1 */\nconst MUTED_LIGHT = \"#4a4d63\";\nconst MUTED_DARK = \"#c1c2d1\";\n\nconst HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;\nconst FUNCTIONAL = /^(?:rgb|rgba|hsl|hsla)\\(\\s*[0-9a-z.%,\\s/+-]+\\)$/i;\n\n/** Reject a brand colour that is not a colour. **REJECT, never escape** — an\n * escaped non-colour still leaves the building and still renders as literal\n * garbage inside a `style` attribute, so the customer sees a broken mail and\n * nobody sees an error. Throwing fails at the CALLER, where someone can act.\n *\n * PROVEN REACHABLE, 2026-09-03, against the built package (F023.9):\n * accentColor = '#0f7391\" onmouseover=\"alert(1)\" x=\"'\n * -> <td bgcolor=\"#0f7391\" onmouseover=\"alert(1)\" x=\"\" ...>\n * a longer payload injected a complete\n * <a href=\"https://phish.example\">Log ind her</a>\n * into the rendered mail. No script needed: a login link inside an otherwise\n * genuine, correctly-branded transactional mail IS the attack, and clients\n * that strip script still render the anchor.\n *\n * It was not reachable when this was written — a single-tenant repo passes a\n * constant from a config file and has no attacker. xrt81 now resolves branding\n * PER TENANT from a database and cardmem's template store is being built. The\n * assumption did not become false through carelessness; the deployment model\n * moved underneath it. */\nexport function assertColor(field: string, value: string | undefined): void {\n if (value === undefined) return;\n const v = value.trim();\n if (HEX.test(v) || FUNCTIONAL.test(v) || NAMED_COLORS.has(v.toLowerCase())) return;\n throw new Error(\n `@broberg/mail-core: ${field} is not a CSS colour (received ${JSON.stringify(value)}). ` +\n `Brand values are interpolated into HTML attributes, so an arbitrary string here can ` +\n `inject markup into the mail. Pass a hex, rgb()/rgba(), hsl()/hsla(), or a named colour.`,\n );\n}\n\n/** A font stack is NOT a colour and must not borrow the colour grammar — it\n * legitimately contains quotes and commas (`'Segoe UI'`). What cannot appear is\n * a tag delimiter or a quote that closes the attribute we sit inside. */\nexport function assertFontStack(field: string, value: string | undefined): void {\n if (value === undefined) return;\n if (!/[<>\"`]/.test(value)) return;\n throw new Error(\n `@broberg/mail-core: ${field} contains a character that can break out of the ` +\n `attribute it is rendered into (received ${JSON.stringify(value)}). ` +\n `Use single quotes for family names: \"-apple-system,'Segoe UI',sans-serif\".`,\n );\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n // Driven from the FIELD NAMES rather than a hand-written list of call sites:\n // a list of seven line numbers goes stale the next time this file is edited,\n // and staleness here reads as coverage. (F023.9 AC#2.)\n assertColor(\"accentColor\", b.accentColor);\n assertColor(\"cardBg\", b.cardBg);\n assertColor(\"textColor\", b.textColor);\n assertColor(\"backdropColor\", b.backdropColor);\n assertFontStack(\"fontSans\", b.fontSans);\n assertFontStack(\"fontSerif\", b.fontSerif);\n\n // #fffffe, not #ffffff, and the one-off byte is the whole point: several\n // clients treat EXACTLY white as \"this is a light mail, invert it\". One step\n // off slips that recognition and no eye can tell the difference. Measured at\n // ZERO effect in Outlook iOS specifically (F023.7) — it is on the list because\n // it works in OTHER clients, not because it rescues that one.\n const cardBg = b.cardBg ?? \"#fffffe\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** How wide to DRAW the logo, in px. Omit and you get the historic centred\n * slot unchanged (`max-width:180px`, no width attribute) — byte-identical to\n * every mail sent before this field existed.\n *\n * SET IT IF YOU CAN, and set it even when 180 is what you want: a supplied\n * width is emitted as an HTML `width` ATTRIBUTE as well as in the style, and\n * **the attribute is the only half Outlook reads.** Outlook's Word engine\n * ignores CSS dimensions on an image, so without the attribute it draws the\n * mark at its full FILE size.\n *\n * WHICH IS WHY THIS EXISTS: vn-leker shipped a 480×480 mark — 2× for a 40px\n * logo, the correct decision — and the shell drew it 180px wide on a 520px\n * card. Christian opened it in Gmail: «Alt for stort logo». **The better the\n * source you supply, the worse the result**; a 96px file would have looked\n * fine. The careful consumer is the one this hits.\n *\n * No `height` attribute is emitted, deliberately: this package serves\n * non-square logos, and a forced square distorts them in exactly the client\n * that honours attributes. */\n logoWidth?: number;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"2\";\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n // A supplied width is emitted as an ATTRIBUTE as well as in the style, because\n // the attribute is the half Outlook reads. Omitted keeps the historic block\n // byte-for-byte — existing production mail must not shift under consumers who\n // never asked for anything.\n //\n // KNOWN AND DELIBERATE: the DEFAULT therefore stays Outlook-unsafe. A caller\n // who omits logoWidth still gets a mark drawn at its full file size in\n // Outlook. Making 180 emit an attribute would fix that for everyone and would\n // change what every existing consumer's mail looks like in one client, which\n // is not a change to make silently. Set logoWidth explicitly.\n const logoW =\n typeof opts.logoWidth === \"number\" && Number.isFinite(opts.logoWidth) && opts.logoWidth > 0\n ? Math.round(opts.logoWidth)\n : null;\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\"${logoW ? ` width=\"${logoW}\"` : \"\"} style=\"display:block;margin:0 auto;${logoW ? `width:${logoW}px` : \"max-width:180px\"};height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n // The footer zone is carried by a COLOURED RULE, not by its fill. fd-sundhed\n // measured card and footer BOTH becoming #484848 in Outlook iOS — the fill\n // stopped distinguishing anything and the zone ceased to exist. What survived\n // was a rule in the brand's own accent. The previous rgba(0,0,0,0.08) is a\n // near-invisible black alpha, i.e. exactly the thing that disappears there.\n //\n // And the text is a real COLOUR, never an opacity. An opacity is not a low\n // contrast value — it is a contrast value FOR ONE BACKGROUND: opacity 0.65 of\n // #1a1c2b measures 5.29:1 while the ground stays white, and lands somewhere\n // nobody measured the moment a client tints or inverts. No contrast tool can\n // read it, because there is no colour there to read.\n // #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #1a1c2b 9.56:1\n // #4a4d63 on #ffffff 8.29:1 #c1c2d1 on #484848 5.18:1 (the mapped case)\n const footerText = isDark(backdropColor) ? MUTED_DARK : MUTED_LIGHT;\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;color:${footerText};\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\"> </td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n assertColor(\"accentColor\", opts?.accentColor);\n assertColor(\"textColor\", opts?.textColor);\n assertFontStack(\"fontSerif\", opts?.fontSerif);\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\n/** One line of a signature, and the tier that styles it.\n *\n * THE INVARIANT, and it is testable rather than a matter of taste: **each tier\n * changes exactly ONE axis against `lead`.** There is no fourth tier waiting,\n * because there is no fourth axis left to spend.\n *\n * lead the base — the size and colour of the surrounding text\n * name + bold (same size, same colour)\n * meta + muted colour (same size, same weight)\n *\n * WHY `name` IS NOT ALSO DARKER, though the obvious signature makes it so:\n * measured on vn-leker's own palette, #1a1c2b is 16.86:1 on white and #0b0e15\n * is 19.29:1. Both are so far past every threshold that the step cannot be\n * seen. The weight does all the work; the colour shift was decoration. Their\n * finding, on their own design.\n *\n * WHY `meta` HAS NO SIZE OF ITS OWN, which is the tempting third axis: a tier\n * carrying a *relative* size step turns a 17/17-bold/15 signature into\n * 15/15-bold/13 in a palette with a smaller base — and 13px secondary text is\n * the exact thing fd-sundhed measured their way out of (13.5px #8486a6 at\n * 3.5:1, failing WCAG in LIGHT mode, before anyone mentioned dark). They went\n * UP in size as part of what doubled legibility. A relative step would quietly\n * roll that back, and the fault would live in a tier definition nobody reads\n * while choosing `meta`. 15px is a measured floor for secondary text in mail.\n */\nexport interface SignOffLine {\n text: string;\n tier?: \"lead\" | \"name\" | \"meta\";\n}\n\n/** The muted tier's colour, one value per background polarity — never an\n * `opacity`, for the reason spelled out on the footer above: an opacity is a\n * contrast value for ONE background only.\n *\n * BOTH POLARITIES EXIST BECAUSE THE SHELL SUPPORTS DARK CARDS, and the first\n * cut of this function did not: a hardcoded #4a4d63 measures **2.10:1** on a\n * #1a1a1a card — far under the 4.5:1 floor, while the README advertises dark\n * cards as a supported mode. That is the same defect this change removed from\n * the footer, reintroduced one function away in the same commit. Found by\n * reviewing the diff, not by any test — which is why the test now renders BOTH\n * polarities and asserts they DIFFER.\n *\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #1a1a1a 2.10:1 <- #c1c2d1 on #484848 5.18:1\n */\nconst SIGNOFF_META_LIGHT = MUTED_LIGHT;\nconst SIGNOFF_META_DARK = MUTED_DARK;\n\nfunction signOffLine(line: SignOffLine, metaColor: string): string {\n const text = escapeHtml(line.text);\n if (line.tier === \"name\") return `<strong style=\"font-weight:700;\">${text}</strong>`;\n if (line.tier === \"meta\") return `<span style=\"color:${metaColor};\">${text}</span>`;\n return text;\n}\n\n/** A signature block.\n *\n * TWO FORMS, and the old one is load-bearing: three repos call\n * `signOff(line1, line2, sign)` in production mail, so it renders\n * byte-identically and always will.\n *\n * THE OLD FORM'S DEFECT, which is why the array form exists: its big slot is\n * the LAST argument and its only axis is size. A name-then-title signature had\n * to be forced into it, and rendered the job title larger than the person —\n * in a mail Christian opened. The API could not express the signature, so the\n * mapping was wrong before anyone wrote a line of calling code.\n *\n * An index-based fix (`{ emphasizeIndex }`) was proposed and rejected: it\n * would place the name and still leave the title nowhere to go, i.e. the same\n * defect in a new shape. It also defaults to index 0 — \"Med venlig hilsen\" —\n * inverting the old form's last-line emphasis for everyone who did not pass\n * the option. vn-leker caught that; it was worse than the bug it fixed.\n */\nexport function signOff(lines: SignOffLine[], opts?: { cardBg?: string }): string;\nexport function signOff(line1: string, line2: string, sign: string): string;\nexport function signOff(\n a: SignOffLine[] | string,\n b?: { cardBg?: string } | string,\n sign?: string,\n): string {\n if (Array.isArray(a) && typeof b === \"object\") assertColor(\"cardBg\", b?.cardBg);\n // The separator carries the original's indentation, so the legacy form is\n // byte-identical rather than merely equivalent. A test asserts that against a\n // stored snapshot; reading it here is not the proof.\n const br = \"<br>\\n \";\n // `meta` follows the card it sits on, using the SAME isDark() the shell uses,\n // so the two cannot drift apart. A caller who omits cardBg gets the light\n // pair, which is exactly what the shell's own default card is.\n const metaColor =\n Array.isArray(a) && typeof b === \"object\" && b?.cardBg && isDark(b.cardBg)\n ? SIGNOFF_META_DARK\n : SIGNOFF_META_LIGHT;\n const body = Array.isArray(a)\n ? a.map((l) => signOffLine(l, metaColor)).join(br)\n // The legacy form — with ONE correction: an empty `sign` used to emit a\n // trailing `<br>` plus `<span style=\"font-size:20px;\"></span>`, i.e. a blank\n // line and an empty styled element that failed nowhere and so survived.\n // vn-leker's own signature replacement left exactly that residue.\n : [escapeHtml(a), escapeHtml(typeof b === \"string\" ? b : \"\")].join(br) +\n (sign ? `${br}<span style=\"font-size:20px;\">${escapeHtml(sign)}</span>` : \"\");\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${body}\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n assertColor(\"accentColor\", opts?.accentColor);\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;color:${MUTED_LIGHT};white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace `{token}` placeholders with values. **Every value is HTML-escaped.**\n * Unknown tokens are left as-is.\n *\n * ⚠️ THE ESCAPING IS THE POINT, and it was missing until 0.6.0. `vars` is\n * dynamic BY DEFINITION — a customer's name, a booking reference, a message\n * someone typed — so every value reaching this function is exactly the class of\n * data that must be escaped. Measured on 0.5.0 and earlier:\n *\n * fill(\"<p>Hej {name}</p>\", { name: '<a href=\"https://phish.example\">Log ind</a>' })\n * -> <p>Hej <a href=\"https://phish.example\">Log ind</a></p>\n *\n * The anchor was in the mail. If you were on an earlier version and passed\n * anything user-supplied through this, assume it rendered as markup.\n *\n * Composing actual markup? Use {@link fillHtml}, whose NAME says so at the call\n * site. There is deliberately no escaping flag: a flag has to default to\n * something, and the wrong default is invisible where it is called.\n *\n * ⚠️ **ORDER MATTERS NOW THAT THIS ESCAPES — RENDER FIRST, THEN FILL.**\n * Filed by cardmem the day the escaping landed, measured in their own store:\n *\n * render THEN fill \"Sørensen & Søn\" -> \"Sørensen & Søn\" ✓\n * fill THEN render \"Sørensen & Søn\" -> \"Sørensen &amp; Søn\" ✗\n *\n * Render first and `{token}` is ordinary text that survives escaping untouched,\n * so each value is escaped exactly once — by the function that substitutes it.\n *\n * It fails in the worst available direction: perfect for every customer whose\n * name has no `&`, `<` or quote, which is most of them. It reaches production\n * looking correct and breaks on one real person, in their inbox, where nobody\n * is watching. If you call both, compose them in ONE function so a call site\n * cannot get the order wrong. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) =>\n key in vars ? escapeHtml(String(vars[key])) : `{${key}}`,\n );\n}\n\n/** Like {@link fill}, but the values are injected as **raw HTML** — nothing is\n * escaped, and the caller owns every value.\n *\n * Mirrors `paragraph` / `paragraphHtml` above: the unsafe one is the one you\n * have to name. Reach for it only when the value is markup you built yourself,\n * never for anything that reached you from a user, a database or a request. */\nexport function fillHtml(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#fffffe' — one byte off white on purpose, so a\n * client looking for EXACTLY #ffffff does not decide the mail wants\n * inverting. Pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\n/** The CSS named colours. The full set on purpose: a guard that rejects\n * `rebeccapurple` is one consumers route around, and a routed-around guard\n * protects nothing. (F023.9 constraint.) */\nconst NAMED_COLORS = new Set(\n (\"aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue \" +\n \"blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk \" +\n \"crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki \" +\n \"darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen \" +\n \"darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue \" +\n \"dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite \" +\n \"gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki \" +\n \"lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan \" +\n \"lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen \" +\n \"lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen \" +\n \"magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen \" +\n \"mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream \" +\n \"mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid \" +\n \"palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum \" +\n \"powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown \" +\n \"seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen \" +\n \"steelblue tan teal thistle tomato transparent turquoise violet wheat white whitesmoke \" +\n \"yellow yellowgreen\").split(\" \"),\n);\n\n/** ONE muted pair for the whole package, not one per function. factBox kept an\n * `opacity:0.65` for a full card after F023.8 removed it from the footer, and\n * the acceptance criterion that should have caught it (\"no opacity on any text\n * in the shell\") passed because its test rendered renderShell and not factBox.\n * A single pair means the next primitive cannot invent a third mid-tone.\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #484848 5.18:1 */\nconst MUTED_LIGHT = \"#4a4d63\";\nconst MUTED_DARK = \"#c1c2d1\";\n\nconst HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;\nconst FUNCTIONAL = /^(?:rgb|rgba|hsl|hsla)\\(\\s*[0-9a-z.%,\\s/+-]+\\)$/i;\n\n/** Reject a brand colour that is not a colour. **REJECT, never escape** — an\n * escaped non-colour still leaves the building and still renders as literal\n * garbage inside a `style` attribute, so the customer sees a broken mail and\n * nobody sees an error. Throwing fails at the CALLER, where someone can act.\n *\n * PROVEN REACHABLE, 2026-09-03, against the built package (F023.9):\n * accentColor = '#0f7391\" onmouseover=\"alert(1)\" x=\"'\n * -> <td bgcolor=\"#0f7391\" onmouseover=\"alert(1)\" x=\"\" ...>\n * a longer payload injected a complete\n * <a href=\"https://phish.example\">Log ind her</a>\n * into the rendered mail. No script needed: a login link inside an otherwise\n * genuine, correctly-branded transactional mail IS the attack, and clients\n * that strip script still render the anchor.\n *\n * It was not reachable when this was written — a single-tenant repo passes a\n * constant from a config file and has no attacker. xrt81 now resolves branding\n * PER TENANT from a database and cardmem's template store is being built. The\n * assumption did not become false through carelessness; the deployment model\n * moved underneath it. */\nexport function assertColor(field: string, value: string | undefined): void {\n if (value === undefined) return;\n const v = value.trim();\n if (HEX.test(v) || FUNCTIONAL.test(v) || NAMED_COLORS.has(v.toLowerCase())) return;\n throw new Error(\n `@broberg/mail-core: ${field} is not a CSS colour (received ${JSON.stringify(value)}). ` +\n `Brand values are interpolated into HTML attributes, so an arbitrary string here can ` +\n `inject markup into the mail. Pass a hex, rgb()/rgba(), hsl()/hsla(), or a named colour.`,\n );\n}\n\n/** A font stack is NOT a colour and must not borrow the colour grammar — it\n * legitimately contains quotes and commas (`'Segoe UI'`). What cannot appear is\n * a tag delimiter or a quote that closes the attribute we sit inside. */\nexport function assertFontStack(field: string, value: string | undefined): void {\n if (value === undefined) return;\n if (!/[<>\"`]/.test(value)) return;\n throw new Error(\n `@broberg/mail-core: ${field} contains a character that can break out of the ` +\n `attribute it is rendered into (received ${JSON.stringify(value)}). ` +\n `Use single quotes for family names: \"-apple-system,'Segoe UI',sans-serif\".`,\n );\n}\n\n// ── contrast ────────────────────────────────────────────────────────────────\n//\n// F023.13. One `accentColor` was doing two jobs — a SURFACE (the top bar, the\n// cta background, a border) and TEXT (the eyebrow, the footer link) — and a\n// brand that works as one is usually illegal as the other.\n//\n// MEASURED on WebHouse gold #F7BB2E, reported by cms and recomputed here:\n//\n// accent as TEXT on white 1.74:1\n// accent as TEXT on the footer's #f4f4f5 1.58:1\n// WHITE label on the accent surface 1.74:1 ← was hardcoded\n// dark label on the accent surface 12.10:1\n//\n// WCAG AA wants 4.5:1. And a FIXED label colour cannot be right: on #0f7391\n// white is correct (5.41) and dark is not (3.22); on gold it is the exact\n// reverse. Only this file sees both sides of the pair, so this file has to pick.\n\n/** WCAG relative luminance. NOT the BT.601 perceived brightness `isDark` uses:\n * that answers \"does this look dark\", which is a different question and gets\n * the boundary wrong. Measured — #808080: isDark says false, so a\n * brightness-based pick would choose WHITE at 3.95:1 over dark at 4.41:1, i.e.\n * the worse of the two. */\nfunction relativeLuminance(r: number, g: number, b: number): number {\n const lin = (c: number) => {\n const v = c / 255;\n return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);\n };\n return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n}\n\n/** #rgb / #rrggbb → [r,g,b], or null for anything else. Deliberately narrow: a\n * functional colour (rgb()/hsl()) or a named one is left ALONE rather than\n * half-parsed, because a wrong contrast decision is worse than no decision. */\nfunction parseHex(value: string): [number, number, number] | null {\n const v = value.trim();\n const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v);\n if (!m) return null;\n const h = m[1]!.length === 3 ? m[1]!.split(\"\").map((c) => c + c).join(\"\") : m[1]!;\n const n = parseInt(h, 16);\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\n/** WCAG contrast ratio between two colours, or null if either is not a hex we\n * parse. Exported so a consumer can assert their own brand before shipping it\n * — the check cms had to write by hand. */\nexport function contrastRatio(a: string, b: string): number | null {\n const x = parseHex(a), y = parseHex(b);\n if (!x || !y) return null;\n const la = relativeLuminance(...x), lb = relativeLuminance(...y);\n return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);\n}\n\n/** The ink to print ON a coloured surface: whichever of the shell's two inks\n * contrasts MORE. Not a threshold — a choice between the only two we have, so\n * it always returns the better one even where neither reaches 4.5:1. */\nexport function readableInk(surface: string): string {\n const dark = contrastRatio(\"#1a1a1a\", surface);\n const light = contrastRatio(\"#ffffff\", surface);\n if (dark === null || light === null) return \"#ffffff\"; // unparseable: today's behaviour\n return dark > light ? \"#1a1a1a\" : \"#ffffff\";\n}\n\n/** An accent used AS TEXT, adjusted until it is legible on `surface` —\n * **and returned UNCHANGED when it already is.** That last clause is what\n * keeps every existing consumer's mail byte-identical: #0f7391 measures 4.92:1\n * on #f4f4f5 and comes back untouched.\n *\n * Moves AWAY from the surface's luminance, so it darkens on a light background\n * and LIGHTENS on a dark one. A \"darken until legible\" helper would be right\n * for cms and wrong for our own dark shell, where the footer link sits on\n * #101010 — measured at 3.52:1 with our own default teal, i.e. already failing\n * before this card existed.\n *\n * Scales all three channels by one factor, which preserves hue and saturation\n * exactly and only moves brightness: the brand stays recognisably the brand. */\nexport function readableAccent(accent: string, surface: string): string {\n const current = contrastRatio(accent, surface);\n if (current === null) return accent; // not a hex we parse — leave it alone\n if (current >= 4.5) return accent; // already legible: DO NOT TOUCH\n\n const rgb = parseHex(accent)!;\n const surf = parseHex(surface)!;\n const goDarker = relativeLuminance(...surf) > 0.5;\n const hex = (c: [number, number, number]) =>\n \"#\" + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, \"0\")).join(\"\");\n\n // 40 steps of 2.5%: fine enough that the result is still visibly the brand,\n // bounded so this can never loop. If even the endpoint fails we return the\n // endpoint — the most legible value available beats silently giving up.\n let best = accent, bestRatio = current;\n for (let i = 1; i <= 40; i++) {\n const t = i / 40;\n const candidate: [number, number, number] = goDarker\n ? [rgb[0] * (1 - t), rgb[1] * (1 - t), rgb[2] * (1 - t)]\n : [rgb[0] + (255 - rgb[0]) * t, rgb[1] + (255 - rgb[1]) * t, rgb[2] + (255 - rgb[2]) * t];\n const h = hex(candidate);\n const r = contrastRatio(h, surface)!;\n if (r > bestRatio) { best = h; bestRatio = r; }\n if (r >= 4.5) return h;\n }\n return best;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n // Driven from the FIELD NAMES rather than a hand-written list of call sites:\n // a list of seven line numbers goes stale the next time this file is edited,\n // and staleness here reads as coverage. (F023.9 AC#2.)\n assertColor(\"accentColor\", b.accentColor);\n assertColor(\"cardBg\", b.cardBg);\n assertColor(\"textColor\", b.textColor);\n assertColor(\"backdropColor\", b.backdropColor);\n assertFontStack(\"fontSans\", b.fontSans);\n assertFontStack(\"fontSerif\", b.fontSerif);\n\n // #fffffe, not #ffffff, and the one-off byte is the whole point: several\n // clients treat EXACTLY white as \"this is a light mail, invert it\". One step\n // off slips that recognition and no eye can tell the difference. Measured at\n // ZERO effect in Outlook iOS specifically (F023.7) — it is on the list because\n // it works in OTHER clients, not because it rescues that one.\n const cardBg = b.cardBg ?? \"#fffffe\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** How wide to DRAW the logo, in px. Omit and you get the historic centred\n * slot unchanged (`max-width:180px`, no width attribute) — byte-identical to\n * every mail sent before this field existed.\n *\n * SET IT IF YOU CAN, and set it even when 180 is what you want: a supplied\n * width is emitted as an HTML `width` ATTRIBUTE as well as in the style, and\n * **the attribute is the only half Outlook reads.** Outlook's Word engine\n * ignores CSS dimensions on an image, so without the attribute it draws the\n * mark at its full FILE size.\n *\n * WHICH IS WHY THIS EXISTS: vn-leker shipped a 480×480 mark — 2× for a 40px\n * logo, the correct decision — and the shell drew it 180px wide on a 520px\n * card. Christian opened it in Gmail: «Alt for stort logo». **The better the\n * source you supply, the worse the result**; a 96px file would have looked\n * fine. The careful consumer is the one this hits.\n *\n * No `height` attribute is emitted, deliberately: this package serves\n * non-square logos, and a forced square distorts them in exactly the client\n * that honours attributes. */\n logoWidth?: number;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"3\";\n// 3 (F023.13): where the accent was used as TEXT — the cta label, the eyebrow,\n// the footer link — the colour is now DERIVED for contrast. A brand that was\n// already legible renders byte-identically (proven against the published 0.7.0\n// across seven shapes); a light brand changes, which is the fix.\n\n/** Warn ONCE per process that a logo is being drawn without an explicit width.\n *\n * vn-leker's proposal, and it is better than either option I had. The problem:\n * omitting `logoWidth` leaves the mark drawn at its full FILE size in Outlook,\n * and we could not tell how many consumers that affects because only the\n * consuming repos know their asset widths. Changing the default would fix it\n * for everyone and silently change one client's rendering for every existing\n * consumer — breaking, and not ours to decide.\n *\n * A warning changes no mail and makes each consumer discover their OWN\n * exposure the next time they run their suite. So the count arrives from\n * measurement instead of from a guess, and a future default change is\n * something everyone has already seen coming.\n *\n * ONCE PER PROCESS, not per render: a transactional mailer renders in a loop,\n * and a warning printed a thousand times is one nobody reads.\n *\n * KNOWN DATA POINT: vn-leker's mark is 480x480 — 2x for a 40px logo, the\n * correct decision by their supplier — drawn at 56px. Broken in Outlook without\n * logoWidth. That is one confirmed YES; the rest of the fleet is unmeasured. */\nlet warnedUnsizedLogo = false;\nfunction warnUnsizedLogo(): void {\n if (warnedUnsizedLogo) return;\n warnedUnsizedLogo = true;\n // eslint-disable-next-line no-console\n console.warn(\n \"@broberg/mail-core: rendering a logo without `logoWidth`. Outlook ignores CSS \" +\n \"dimensions on an image, so it will draw your file at its FULL width there — \" +\n \"a 480px source becomes a 480px logo. Pass logoWidth (e.g. { logoWidth: 56 }) \" +\n \"even if 180 is what you want. This warns once per process.\",\n );\n}\n\n/** Test seam: reset the once-per-process warning. Exported because a test that\n * cannot re-arm the warning can only ever assert it fires the FIRST time, which\n * proves the flag exists rather than that the condition is right. */\nexport function __resetLogoWarning(): void {\n warnedUnsizedLogo = false;\n}\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n // A supplied width is emitted as an ATTRIBUTE as well as in the style, because\n // the attribute is the half Outlook reads. Omitted keeps the historic block\n // byte-for-byte — existing production mail must not shift under consumers who\n // never asked for anything.\n //\n // KNOWN AND DELIBERATE: the DEFAULT therefore stays Outlook-unsafe. A caller\n // who omits logoWidth still gets a mark drawn at its full file size in\n // Outlook. Making 180 emit an attribute would fix that for everyone and would\n // change what every existing consumer's mail looks like in one client, which\n // is not a change to make silently. Set logoWidth explicitly.\n if (logoSrc && opts.logoWidth === undefined) warnUnsizedLogo();\n const logoW =\n typeof opts.logoWidth === \"number\" && Number.isFinite(opts.logoWidth) && opts.logoWidth > 0\n ? Math.round(opts.logoWidth)\n : null;\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\"${logoW ? ` width=\"${logoW}\"` : \"\"} style=\"display:block;margin:0 auto;${logoW ? `width:${logoW}px` : \"max-width:180px\"};height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n // The footer zone is carried by a COLOURED RULE, not by its fill. fd-sundhed\n // measured card and footer BOTH becoming #484848 in Outlook iOS — the fill\n // stopped distinguishing anything and the zone ceased to exist. What survived\n // was a rule in the brand's own accent. The previous rgba(0,0,0,0.08) is a\n // near-invisible black alpha, i.e. exactly the thing that disappears there.\n //\n // And the text is a real COLOUR, never an opacity. An opacity is not a low\n // contrast value — it is a contrast value FOR ONE BACKGROUND: opacity 0.65 of\n // #1a1c2b measures 5.29:1 while the ground stays white, and lands somewhere\n // nobody measured the moment a client tints or inverts. No contrast tool can\n // read it, because there is no colour there to read.\n // #4a4d63 on #f4f4f5 7.54:1 #c1c2d1 on #1a1c2b 9.56:1\n // #4a4d63 on #ffffff 8.29:1 #c1c2d1 on #484848 5.18:1 (the mapped case)\n const footerText = isDark(backdropColor) ? MUTED_DARK : MUTED_LIGHT;\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;color:${footerText};\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${readableAccent(accentColor, backdropColor)};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\"> </td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n assertColor(\"accentColor\", opts?.accentColor);\n assertColor(\"textColor\", opts?.textColor);\n assertFontStack(\"fontSerif\", opts?.fontSerif);\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string; surface?: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n assertColor(\"surface\", opts.surface);\n // The eyebrow sits on the CARD, so that is what it is measured against —\n // #fffffe by default, matching resolveColors. `surface` exists for a caller\n // who renders it somewhere else; it is not a colour override.\n const colour = readableAccent(opts.accentColor, opts.surface ?? \"#fffffe\");\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${colour};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\n/** One line of a signature, and the tier that styles it.\n *\n * THE INVARIANT, and it is testable rather than a matter of taste: **each tier\n * changes exactly ONE axis against `lead`.** There is no fourth tier waiting,\n * because there is no fourth axis left to spend.\n *\n * lead the base — the size and colour of the surrounding text\n * name + bold (same size, same colour)\n * meta + muted colour (same size, same weight)\n *\n * WHY `name` IS NOT ALSO DARKER, though the obvious signature makes it so:\n * measured on vn-leker's own palette, #1a1c2b is 16.86:1 on white and #0b0e15\n * is 19.29:1. Both are so far past every threshold that the step cannot be\n * seen. The weight does all the work; the colour shift was decoration. Their\n * finding, on their own design.\n *\n * WHY `meta` HAS NO SIZE OF ITS OWN, which is the tempting third axis: a tier\n * carrying a *relative* size step turns a 17/17-bold/15 signature into\n * 15/15-bold/13 in a palette with a smaller base — and 13px secondary text is\n * the exact thing fd-sundhed measured their way out of (13.5px #8486a6 at\n * 3.5:1, failing WCAG in LIGHT mode, before anyone mentioned dark). They went\n * UP in size as part of what doubled legibility. A relative step would quietly\n * roll that back, and the fault would live in a tier definition nobody reads\n * while choosing `meta`. 15px is a measured floor for secondary text in mail.\n */\nexport interface SignOffLine {\n text: string;\n tier?: \"lead\" | \"name\" | \"meta\";\n}\n\n/** The muted tier's colour, one value per background polarity — never an\n * `opacity`, for the reason spelled out on the footer above: an opacity is a\n * contrast value for ONE background only.\n *\n * BOTH POLARITIES EXIST BECAUSE THE SHELL SUPPORTS DARK CARDS, and the first\n * cut of this function did not: a hardcoded #4a4d63 measures **2.10:1** on a\n * #1a1a1a card — far under the 4.5:1 floor, while the README advertises dark\n * cards as a supported mode. That is the same defect this change removed from\n * the footer, reintroduced one function away in the same commit. Found by\n * reviewing the diff, not by any test — which is why the test now renders BOTH\n * polarities and asserts they DIFFER.\n *\n * #4a4d63 on #fffffe 8.29:1 #c1c2d1 on #1a1a1a 9.87:1\n * #4a4d63 on #1a1a1a 2.10:1 <- #c1c2d1 on #484848 5.18:1\n */\nconst SIGNOFF_META_LIGHT = MUTED_LIGHT;\nconst SIGNOFF_META_DARK = MUTED_DARK;\n\nfunction signOffLine(line: SignOffLine, metaColor: string): string {\n const text = escapeHtml(line.text);\n if (line.tier === \"name\") return `<strong style=\"font-weight:700;\">${text}</strong>`;\n if (line.tier === \"meta\") return `<span style=\"color:${metaColor};\">${text}</span>`;\n return text;\n}\n\n/** A signature block.\n *\n * TWO FORMS, and the old one is load-bearing: three repos call\n * `signOff(line1, line2, sign)` in production mail, so it renders\n * byte-identically and always will.\n *\n * THE OLD FORM'S DEFECT, which is why the array form exists: its big slot is\n * the LAST argument and its only axis is size. A name-then-title signature had\n * to be forced into it, and rendered the job title larger than the person —\n * in a mail Christian opened. The API could not express the signature, so the\n * mapping was wrong before anyone wrote a line of calling code.\n *\n * An index-based fix (`{ emphasizeIndex }`) was proposed and rejected: it\n * would place the name and still leave the title nowhere to go, i.e. the same\n * defect in a new shape. It also defaults to index 0 — \"Med venlig hilsen\" —\n * inverting the old form's last-line emphasis for everyone who did not pass\n * the option. vn-leker caught that; it was worse than the bug it fixed.\n */\nexport function signOff(lines: SignOffLine[], opts?: { cardBg?: string }): string;\nexport function signOff(line1: string, line2: string, sign: string): string;\nexport function signOff(\n a: SignOffLine[] | string,\n b?: { cardBg?: string } | string,\n sign?: string,\n): string {\n if (Array.isArray(a) && typeof b === \"object\") assertColor(\"cardBg\", b?.cardBg);\n // The separator carries the original's indentation, so the legacy form is\n // byte-identical rather than merely equivalent. A test asserts that against a\n // stored snapshot; reading it here is not the proof.\n const br = \"<br>\\n \";\n // `meta` follows the card it sits on, using the SAME isDark() the shell uses,\n // so the two cannot drift apart. A caller who omits cardBg gets the light\n // pair, which is exactly what the shell's own default card is.\n const metaColor =\n Array.isArray(a) && typeof b === \"object\" && b?.cardBg && isDark(b.cardBg)\n ? SIGNOFF_META_DARK\n : SIGNOFF_META_LIGHT;\n const body = Array.isArray(a)\n ? a.map((l) => signOffLine(l, metaColor)).join(br)\n // The legacy form — with ONE correction: an empty `sign` used to emit a\n // trailing `<br>` plus `<span style=\"font-size:20px;\"></span>`, i.e. a blank\n // line and an empty styled element that failed nowhere and so survived.\n // vn-leker's own signature replacement left exactly that residue.\n : [escapeHtml(a), escapeHtml(typeof b === \"string\" ? b : \"\")].join(br) +\n (sign ? `${br}<span style=\"font-size:20px;\">${escapeHtml(sign)}</span>` : \"\");\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${body}\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n assertColor(\"accentColor\", opts.accentColor);\n // The BACKGROUND keeps the brand colour exactly; only the LABEL is derived.\n // That is the half cms could not fix from outside: their workaround had to\n // darken the button itself, so the button stopped being WebHouse gold.\n const ink = readableInk(opts.accentColor);\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:${ink};text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n assertColor(\"accentColor\", opts?.accentColor);\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;color:${MUTED_LIGHT};white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace `{token}` placeholders with values. **Every value is HTML-escaped.**\n * Unknown tokens are left as-is.\n *\n * ⚠️ THE ESCAPING IS THE POINT, and it was missing until 0.6.0. `vars` is\n * dynamic BY DEFINITION — a customer's name, a booking reference, a message\n * someone typed — so every value reaching this function is exactly the class of\n * data that must be escaped. Measured on 0.5.0 and earlier:\n *\n * fill(\"<p>Hej {name}</p>\", { name: '<a href=\"https://phish.example\">Log ind</a>' })\n * -> <p>Hej <a href=\"https://phish.example\">Log ind</a></p>\n *\n * The anchor was in the mail. If you were on an earlier version and passed\n * anything user-supplied through this, assume it rendered as markup.\n *\n * Composing actual markup? Use {@link fillHtml}, whose NAME says so at the call\n * site. There is deliberately no escaping flag: a flag has to default to\n * something, and the wrong default is invisible where it is called.\n *\n * ⚠️ **ORDER MATTERS NOW THAT THIS ESCAPES — RENDER FIRST, THEN FILL.**\n * Filed by cardmem the day the escaping landed, measured in their own store:\n *\n * render THEN fill \"Sørensen & Søn\" -> \"Sørensen & Søn\" ✓\n * fill THEN render \"Sørensen & Søn\" -> \"Sørensen &amp; Søn\" ✗\n *\n * Render first and `{token}` is ordinary text that survives escaping untouched,\n * so each value is escaped exactly once — by the function that substitutes it.\n *\n * It fails in the worst available direction: perfect for every customer whose\n * name has no `&`, `<` or quote, which is most of them. It reaches production\n * looking correct and breaks on one real person, in their inbox, where nobody\n * is watching. If you call both, compose them in ONE function so a call site\n * cannot get the order wrong. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) =>\n key in vars ? escapeHtml(String(vars[key])) : `{${key}}`,\n );\n}\n\n/** Like {@link fill}, but the values are injected as **raw HTML** — nothing is\n * escaped, and the caller owns every value.\n *\n * Mirrors `paragraph` / `paragraphHtml` above: the unsafe one is the one you\n * have to name. Reach for it only when the value is markup you built yourself,\n * never for anything that reached you from a user, a database or a request. */\nexport function fillHtml(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/mail-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Branded HTML email shell + primitives for the broberg.ai fleet — renderShell, heading/paragraph/cta/factBox/signOff, eyebrow/noteBox, a three-tier signOff, and a CID logo-attachment helper. No sending (@broberg/mail) and no template storage (that lives in cardmem) — layer 1 (visual structure) only. Every brand value is a caller-supplied param.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|