@broberg/mail-core 0.4.0 → 0.6.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 CHANGED
@@ -132,3 +132,78 @@ put that anchor into the rendered mail. No script is involved — a login link
132
132
  inside an otherwise genuine, correctly-branded transactional mail is the whole
133
133
  attack, and clients that strip script still render it. Validate at your own
134
134
  boundary too; this is the last line, not the only one.
135
+
136
+ ## Sizing the logo — set `logoWidth`, and set it even when 180 is what you want
137
+
138
+ ```ts
139
+ renderShell({ …, logoUrl, logoWidth: 56 })
140
+ ```
141
+
142
+ Omit it and you get the historic centred slot unchanged (`max-width:180px`, no
143
+ width attribute) — byte-identical to every mail sent before the field existed.
144
+
145
+ **Set it if you can.** A supplied width is emitted as an HTML `width` **attribute**
146
+ as well as in the style, and **the attribute is the only half Outlook reads** —
147
+ its Word engine ignores CSS dimensions on an image, so without the attribute it
148
+ draws your mark at its full *file* size.
149
+
150
+ That is also why the default remains Outlook-unsafe, deliberately: making 180
151
+ emit an attribute would fix it for everyone and would change what every existing
152
+ consumer's mail looks like in one client, which is not a change to make silently.
153
+
154
+ **Why this field exists.** A consumer shipped a 480×480 mark — 2× for a 40px
155
+ logo, the correct decision — and the shell drew it 180px wide on a 520px card.
156
+ **The better the source you supply, the worse the result**; a 96px file would have
157
+ looked fine. This hits the careful consumer, not the careless one.
158
+
159
+ No `height` attribute is emitted: this package serves non-square logos, and a
160
+ forced square distorts them in exactly the client that honours attributes.
161
+
162
+ `logoWidth` is how you **draw** the logo. If you also need to **produce** it at a
163
+ sane size, that is `@broberg/media-transform` — complementary, not an alternative.
164
+
165
+ ## `fill()` escapes — and the order matters
166
+
167
+ ```ts
168
+ fill(template, vars) // every value HTML-escaped
169
+ fillHtml(template, vars) // raw; the unsafe one is the one you have to name
170
+ ```
171
+
172
+ **Before 0.6.0, `fill()` did not escape.** `vars` is dynamic by definition — a
173
+ customer's name, a booking reference, something someone typed — so if you were on
174
+ an earlier version and passed anything user-supplied through it, assume it
175
+ rendered as markup. Measured on 0.5.0:
176
+
177
+ ```
178
+ fill("<p>Hej {name}</p>", { name: '<a href="https://phish.example">Log ind</a>' })
179
+ → <p>Hej <a href="https://phish.example">Log ind</a></p>
180
+ ```
181
+
182
+ **⚠️ Render first, then fill.**
183
+
184
+ ```
185
+ render THEN fill "Sørensen & Søn" → "Sørensen &amp; Søn" ✓
186
+ fill THEN render "Sørensen & Søn" → "Sørensen &amp;amp; Søn" ✗
187
+ ```
188
+
189
+ Render first and `{token}` is ordinary text that survives escaping untouched, so
190
+ each value is escaped exactly once. The wrong order fails in the worst available
191
+ direction: perfect for every customer whose name has no `&`, `<` or quote — most
192
+ of them — reaching production looking correct and breaking on one real person, in
193
+ their inbox, where nobody is watching. If you call both, compose them into one
194
+ function so a call site cannot get the order wrong. (Filed by cardmem, who hit it
195
+ in their own template store.)
196
+
197
+ ## `SHELL_VERSION` — and what it was worth before 0.6.0
198
+
199
+ `SHELL_VERSION` is emitted into every mail as `<!-- @broberg/mail-core shell vN -->`
200
+ right after the doctype, so you can tell *"my template changed"* from *"their
201
+ shell changed"* by reading a stored render.
202
+
203
+ **It read `"1"` across 0.2.x, 0.3.x and 0.4.x while the rendering changed three
204
+ times** — the footer rule, the footer text colour, the card background,
205
+ `signOff`'s emphasis. A stored `"1"` therefore proves nothing about which shell
206
+ rendered a mail. It is `"2"` from 0.6.0, and a test now compares the rendered
207
+ output against a fixture keyed by the marker: change the output without bumping
208
+ the version and the build fails. Two numbers answer two questions — read the
209
+ installed package version alongside it.
package/dist/index.cjs CHANGED
@@ -12,6 +12,8 @@ function escapeAttr(s) {
12
12
  var NAMED_COLORS = new Set(
13
13
  "aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato transparent turquoise violet wheat white whitesmoke yellow yellowgreen".split(" ")
14
14
  );
15
+ var MUTED_LIGHT = "#4a4d63";
16
+ var MUTED_DARK = "#c1c2d1";
15
17
  var HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
16
18
  var FUNCTIONAL = /^(?:rgb|rgba|hsl|hsla)\(\s*[0-9a-z.%,\s/+-]+\)$/i;
17
19
  function assertColor(field, value) {
@@ -58,19 +60,20 @@ function resolveLogoSrc(logo, fallbackUrl) {
58
60
  if (/^data:/i.test(url)) return null;
59
61
  return url;
60
62
  }
61
- var SHELL_VERSION = "1";
63
+ var SHELL_VERSION = "2";
62
64
  function renderShell(opts) {
63
65
  const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);
64
66
  const lang = opts.lang ?? "en";
65
67
  const showFooter = opts.showFooter ?? true;
66
68
  const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);
67
69
  const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? "";
70
+ const logoW = typeof opts.logoWidth === "number" && Number.isFinite(opts.logoWidth) && opts.logoWidth > 0 ? Math.round(opts.logoWidth) : null;
68
71
  const logoBlock = logoSrc ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto 16px;">
69
72
  <tr><td>
70
- <img src="${escapeAttr(logoSrc)}" alt="${escapeAttr(logoAlt)}" style="display:block;margin:0 auto;max-width:180px;height:auto;border:0;">
73
+ <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;">
71
74
  </td></tr>
72
75
  </table>` : "";
73
- const footerText = isDark(backdropColor) ? "#c1c2d1" : "#4a4d63";
76
+ const footerText = isDark(backdropColor) ? MUTED_DARK : MUTED_LIGHT;
74
77
  const footerBlock = showFooter ? `<tr>
75
78
  <td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};">
76
79
  ${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;color:${footerText};">${escapeHtml(l)}</p>`).join("")}
@@ -177,8 +180,8 @@ function paragraph(text) {
177
180
  function paragraphHtml(html) {
178
181
  return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${html}</p>`;
179
182
  }
180
- var SIGNOFF_META_LIGHT = "#4a4d63";
181
- var SIGNOFF_META_DARK = "#c1c2d1";
183
+ var SIGNOFF_META_LIGHT = MUTED_LIGHT;
184
+ var SIGNOFF_META_DARK = MUTED_DARK;
182
185
  function signOffLine(line, metaColor) {
183
186
  const text = escapeHtml(line.text);
184
187
  if (line.tier === "name") return `<strong style="font-weight:700;">${text}</strong>`;
@@ -212,7 +215,7 @@ function factBox(rows, opts) {
212
215
  const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : "border:1px solid rgba(0,0,0,0.1);";
213
216
  const cells = rows.map(
214
217
  (r) => `<tr>
215
- <td style="padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;">${escapeHtml(r.label)}</td>
218
+ <td style="padding:6px 12px 6px 0;font-size:13px;color:${MUTED_LIGHT};white-space:nowrap;vertical-align:top;">${escapeHtml(r.label)}</td>
216
219
  <td style="padding:6px 0;font-size:13px;font-weight:600;">${escapeHtml(r.value)}</td>
217
220
  </tr>`
218
221
  ).join("");
@@ -223,6 +226,12 @@ function factBox(rows, opts) {
223
226
  </table>`;
224
227
  }
225
228
  function fill(template, vars) {
229
+ return template.replace(
230
+ /\{(\w+)\}/g,
231
+ (_, key) => key in vars ? escapeHtml(String(vars[key])) : `{${key}}`
232
+ );
233
+ }
234
+ function fillHtml(template, vars) {
226
235
  return template.replace(/\{(\w+)\}/g, (_, key) => key in vars ? String(vars[key]) : `{${key}}`);
227
236
  }
228
237
  function makeLogoAttachment(filePath, opts) {
@@ -246,6 +255,7 @@ exports.escapeHtml = escapeHtml;
246
255
  exports.eyebrow = eyebrow;
247
256
  exports.factBox = factBox;
248
257
  exports.fill = fill;
258
+ exports.fillHtml = fillHtml;
249
259
  exports.heading = heading;
250
260
  exports.makeLogoAttachment = makeLogoAttachment;
251
261
  exports.noteBox = noteBox;
@@ -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) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[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\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 /** 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 = \"1\";\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 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)}\" style=\"display:block;margin:0 auto;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) ? \"#c1c2d1\" : \"#4a4d63\";\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;\">&nbsp;</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 = \"#4a4d63\";\nconst SIGNOFF_META_DARK = \"#c1c2d1\";\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;opacity:0.65;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. Unknown tokens are left as-is. */\nexport function fill(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":"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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) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[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;\">&nbsp;</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 &amp; Søn\" ✓\n * fill THEN render \"Sørensen & Søn\" -> \"Sørensen &amp;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
@@ -67,6 +67,26 @@ interface ShellOpts extends BrandColors {
67
67
  * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */
68
68
  logoUrl?: string;
69
69
  logoAlt?: string;
70
+ /** How wide to DRAW the logo, in px. Omit and you get the historic centred
71
+ * slot unchanged (`max-width:180px`, no width attribute) — byte-identical to
72
+ * every mail sent before this field existed.
73
+ *
74
+ * SET IT IF YOU CAN, and set it even when 180 is what you want: a supplied
75
+ * width is emitted as an HTML `width` ATTRIBUTE as well as in the style, and
76
+ * **the attribute is the only half Outlook reads.** Outlook's Word engine
77
+ * ignores CSS dimensions on an image, so without the attribute it draws the
78
+ * mark at its full FILE size.
79
+ *
80
+ * WHICH IS WHY THIS EXISTS: vn-leker shipped a 480×480 mark — 2× for a 40px
81
+ * logo, the correct decision — and the shell drew it 180px wide on a 520px
82
+ * card. Christian opened it in Gmail: «Alt for stort logo». **The better the
83
+ * source you supply, the worse the result**; a 96px file would have looked
84
+ * fine. The careful consumer is the one this hits.
85
+ *
86
+ * No `height` attribute is emitted, deliberately: this package serves
87
+ * non-square logos, and a forced square distorts them in exactly the client
88
+ * that honours attributes. */
89
+ logoWidth?: number;
70
90
  /** The logo, expressed as EVERY form you have, in preference order (F023.7).
71
91
  *
72
92
  * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends
@@ -116,7 +136,7 @@ declare function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: stri
116
136
  * An HTML COMMENT rather than an attribute: comments survive every client we
117
137
  * have measured, and an attribute on <html> is one of the first things a
118
138
  * sanitising webmail rewrites. */
119
- declare const SHELL_VERSION = "1";
139
+ declare const SHELL_VERSION = "2";
120
140
  declare function renderShell(opts: ShellOpts): string;
121
141
  /** `emphasis` italicises the FIRST occurrence of that substring in the accent
122
142
  * colour — the "one word picked out of the headline" brand signature three
@@ -222,8 +242,46 @@ interface FactRow {
222
242
  declare function factBox(rows: FactRow[], opts?: {
223
243
  accentColor?: string;
224
244
  }): string;
225
- /** Replace {token} placeholders with values. Unknown tokens are left as-is. */
245
+ /** Replace `{token}` placeholders with values. **Every value is HTML-escaped.**
246
+ * Unknown tokens are left as-is.
247
+ *
248
+ * ⚠️ THE ESCAPING IS THE POINT, and it was missing until 0.6.0. `vars` is
249
+ * dynamic BY DEFINITION — a customer's name, a booking reference, a message
250
+ * someone typed — so every value reaching this function is exactly the class of
251
+ * data that must be escaped. Measured on 0.5.0 and earlier:
252
+ *
253
+ * fill("<p>Hej {name}</p>", { name: '<a href="https://phish.example">Log ind</a>' })
254
+ * -> <p>Hej <a href="https://phish.example">Log ind</a></p>
255
+ *
256
+ * The anchor was in the mail. If you were on an earlier version and passed
257
+ * anything user-supplied through this, assume it rendered as markup.
258
+ *
259
+ * Composing actual markup? Use {@link fillHtml}, whose NAME says so at the call
260
+ * site. There is deliberately no escaping flag: a flag has to default to
261
+ * something, and the wrong default is invisible where it is called.
262
+ *
263
+ * ⚠️ **ORDER MATTERS NOW THAT THIS ESCAPES — RENDER FIRST, THEN FILL.**
264
+ * Filed by cardmem the day the escaping landed, measured in their own store:
265
+ *
266
+ * render THEN fill "Sørensen & Søn" -> "Sørensen &amp; Søn" ✓
267
+ * fill THEN render "Sørensen & Søn" -> "Sørensen &amp;amp; Søn" ✗
268
+ *
269
+ * Render first and `{token}` is ordinary text that survives escaping untouched,
270
+ * so each value is escaped exactly once — by the function that substitutes it.
271
+ *
272
+ * It fails in the worst available direction: perfect for every customer whose
273
+ * name has no `&`, `<` or quote, which is most of them. It reaches production
274
+ * looking correct and breaks on one real person, in their inbox, where nobody
275
+ * is watching. If you call both, compose them in ONE function so a call site
276
+ * cannot get the order wrong. */
226
277
  declare function fill(template: string, vars: Record<string, string | number>): string;
278
+ /** Like {@link fill}, but the values are injected as **raw HTML** — nothing is
279
+ * escaped, and the caller owns every value.
280
+ *
281
+ * Mirrors `paragraph` / `paragraphHtml` above: the unsafe one is the one you
282
+ * have to name. Reach for it only when the value is markup you built yourself,
283
+ * never for anything that reached you from a user, a database or a request. */
284
+ declare function fillHtml(template: string, vars: Record<string, string | number>): string;
227
285
  interface MailAttachment {
228
286
  filename: string;
229
287
  content: Buffer;
@@ -238,4 +296,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
238
296
  contentType?: string;
239
297
  }): MailAttachment | null;
240
298
 
241
- export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, assertColor, assertFontStack, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
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 };
package/dist/index.d.ts CHANGED
@@ -67,6 +67,26 @@ interface ShellOpts extends BrandColors {
67
67
  * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */
68
68
  logoUrl?: string;
69
69
  logoAlt?: string;
70
+ /** How wide to DRAW the logo, in px. Omit and you get the historic centred
71
+ * slot unchanged (`max-width:180px`, no width attribute) — byte-identical to
72
+ * every mail sent before this field existed.
73
+ *
74
+ * SET IT IF YOU CAN, and set it even when 180 is what you want: a supplied
75
+ * width is emitted as an HTML `width` ATTRIBUTE as well as in the style, and
76
+ * **the attribute is the only half Outlook reads.** Outlook's Word engine
77
+ * ignores CSS dimensions on an image, so without the attribute it draws the
78
+ * mark at its full FILE size.
79
+ *
80
+ * WHICH IS WHY THIS EXISTS: vn-leker shipped a 480×480 mark — 2× for a 40px
81
+ * logo, the correct decision — and the shell drew it 180px wide on a 520px
82
+ * card. Christian opened it in Gmail: «Alt for stort logo». **The better the
83
+ * source you supply, the worse the result**; a 96px file would have looked
84
+ * fine. The careful consumer is the one this hits.
85
+ *
86
+ * No `height` attribute is emitted, deliberately: this package serves
87
+ * non-square logos, and a forced square distorts them in exactly the client
88
+ * that honours attributes. */
89
+ logoWidth?: number;
70
90
  /** The logo, expressed as EVERY form you have, in preference order (F023.7).
71
91
  *
72
92
  * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends
@@ -116,7 +136,7 @@ declare function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: stri
116
136
  * An HTML COMMENT rather than an attribute: comments survive every client we
117
137
  * have measured, and an attribute on <html> is one of the first things a
118
138
  * sanitising webmail rewrites. */
119
- declare const SHELL_VERSION = "1";
139
+ declare const SHELL_VERSION = "2";
120
140
  declare function renderShell(opts: ShellOpts): string;
121
141
  /** `emphasis` italicises the FIRST occurrence of that substring in the accent
122
142
  * colour — the "one word picked out of the headline" brand signature three
@@ -222,8 +242,46 @@ interface FactRow {
222
242
  declare function factBox(rows: FactRow[], opts?: {
223
243
  accentColor?: string;
224
244
  }): string;
225
- /** Replace {token} placeholders with values. Unknown tokens are left as-is. */
245
+ /** Replace `{token}` placeholders with values. **Every value is HTML-escaped.**
246
+ * Unknown tokens are left as-is.
247
+ *
248
+ * ⚠️ THE ESCAPING IS THE POINT, and it was missing until 0.6.0. `vars` is
249
+ * dynamic BY DEFINITION — a customer's name, a booking reference, a message
250
+ * someone typed — so every value reaching this function is exactly the class of
251
+ * data that must be escaped. Measured on 0.5.0 and earlier:
252
+ *
253
+ * fill("<p>Hej {name}</p>", { name: '<a href="https://phish.example">Log ind</a>' })
254
+ * -> <p>Hej <a href="https://phish.example">Log ind</a></p>
255
+ *
256
+ * The anchor was in the mail. If you were on an earlier version and passed
257
+ * anything user-supplied through this, assume it rendered as markup.
258
+ *
259
+ * Composing actual markup? Use {@link fillHtml}, whose NAME says so at the call
260
+ * site. There is deliberately no escaping flag: a flag has to default to
261
+ * something, and the wrong default is invisible where it is called.
262
+ *
263
+ * ⚠️ **ORDER MATTERS NOW THAT THIS ESCAPES — RENDER FIRST, THEN FILL.**
264
+ * Filed by cardmem the day the escaping landed, measured in their own store:
265
+ *
266
+ * render THEN fill "Sørensen & Søn" -> "Sørensen &amp; Søn" ✓
267
+ * fill THEN render "Sørensen & Søn" -> "Sørensen &amp;amp; Søn" ✗
268
+ *
269
+ * Render first and `{token}` is ordinary text that survives escaping untouched,
270
+ * so each value is escaped exactly once — by the function that substitutes it.
271
+ *
272
+ * It fails in the worst available direction: perfect for every customer whose
273
+ * name has no `&`, `<` or quote, which is most of them. It reaches production
274
+ * looking correct and breaks on one real person, in their inbox, where nobody
275
+ * is watching. If you call both, compose them in ONE function so a call site
276
+ * cannot get the order wrong. */
226
277
  declare function fill(template: string, vars: Record<string, string | number>): string;
278
+ /** Like {@link fill}, but the values are injected as **raw HTML** — nothing is
279
+ * escaped, and the caller owns every value.
280
+ *
281
+ * Mirrors `paragraph` / `paragraphHtml` above: the unsafe one is the one you
282
+ * have to name. Reach for it only when the value is markup you built yourself,
283
+ * never for anything that reached you from a user, a database or a request. */
284
+ declare function fillHtml(template: string, vars: Record<string, string | number>): string;
227
285
  interface MailAttachment {
228
286
  filename: string;
229
287
  content: Buffer;
@@ -238,4 +296,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
238
296
  contentType?: string;
239
297
  }): MailAttachment | null;
240
298
 
241
- export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, assertColor, assertFontStack, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
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 };
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@ function escapeAttr(s) {
10
10
  var NAMED_COLORS = new Set(
11
11
  "aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato transparent turquoise violet wheat white whitesmoke yellow yellowgreen".split(" ")
12
12
  );
13
+ var MUTED_LIGHT = "#4a4d63";
14
+ var MUTED_DARK = "#c1c2d1";
13
15
  var HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
14
16
  var FUNCTIONAL = /^(?:rgb|rgba|hsl|hsla)\(\s*[0-9a-z.%,\s/+-]+\)$/i;
15
17
  function assertColor(field, value) {
@@ -56,19 +58,20 @@ function resolveLogoSrc(logo, fallbackUrl) {
56
58
  if (/^data:/i.test(url)) return null;
57
59
  return url;
58
60
  }
59
- var SHELL_VERSION = "1";
61
+ var SHELL_VERSION = "2";
60
62
  function renderShell(opts) {
61
63
  const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);
62
64
  const lang = opts.lang ?? "en";
63
65
  const showFooter = opts.showFooter ?? true;
64
66
  const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);
65
67
  const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? "";
68
+ const logoW = typeof opts.logoWidth === "number" && Number.isFinite(opts.logoWidth) && opts.logoWidth > 0 ? Math.round(opts.logoWidth) : null;
66
69
  const logoBlock = logoSrc ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto 16px;">
67
70
  <tr><td>
68
- <img src="${escapeAttr(logoSrc)}" alt="${escapeAttr(logoAlt)}" style="display:block;margin:0 auto;max-width:180px;height:auto;border:0;">
71
+ <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;">
69
72
  </td></tr>
70
73
  </table>` : "";
71
- const footerText = isDark(backdropColor) ? "#c1c2d1" : "#4a4d63";
74
+ const footerText = isDark(backdropColor) ? MUTED_DARK : MUTED_LIGHT;
72
75
  const footerBlock = showFooter ? `<tr>
73
76
  <td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid ${accentColor};">
74
77
  ${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;color:${footerText};">${escapeHtml(l)}</p>`).join("")}
@@ -175,8 +178,8 @@ function paragraph(text) {
175
178
  function paragraphHtml(html) {
176
179
  return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${html}</p>`;
177
180
  }
178
- var SIGNOFF_META_LIGHT = "#4a4d63";
179
- var SIGNOFF_META_DARK = "#c1c2d1";
181
+ var SIGNOFF_META_LIGHT = MUTED_LIGHT;
182
+ var SIGNOFF_META_DARK = MUTED_DARK;
180
183
  function signOffLine(line, metaColor) {
181
184
  const text = escapeHtml(line.text);
182
185
  if (line.tier === "name") return `<strong style="font-weight:700;">${text}</strong>`;
@@ -210,7 +213,7 @@ function factBox(rows, opts) {
210
213
  const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : "border:1px solid rgba(0,0,0,0.1);";
211
214
  const cells = rows.map(
212
215
  (r) => `<tr>
213
- <td style="padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;">${escapeHtml(r.label)}</td>
216
+ <td style="padding:6px 12px 6px 0;font-size:13px;color:${MUTED_LIGHT};white-space:nowrap;vertical-align:top;">${escapeHtml(r.label)}</td>
214
217
  <td style="padding:6px 0;font-size:13px;font-weight:600;">${escapeHtml(r.value)}</td>
215
218
  </tr>`
216
219
  ).join("");
@@ -221,6 +224,12 @@ function factBox(rows, opts) {
221
224
  </table>`;
222
225
  }
223
226
  function fill(template, vars) {
227
+ return template.replace(
228
+ /\{(\w+)\}/g,
229
+ (_, key) => key in vars ? escapeHtml(String(vars[key])) : `{${key}}`
230
+ );
231
+ }
232
+ function fillHtml(template, vars) {
224
233
  return template.replace(/\{(\w+)\}/g, (_, key) => key in vars ? String(vars[key]) : `{${key}}`);
225
234
  }
226
235
  function makeLogoAttachment(filePath, opts) {
@@ -235,6 +244,6 @@ function makeLogoAttachment(filePath, opts) {
235
244
  }
236
245
  }
237
246
 
238
- export { SHELL_VERSION, assertColor, assertFontStack, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
247
+ export { SHELL_VERSION, assertColor, assertFontStack, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, fillHtml, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
239
248
  //# sourceMappingURL=index.js.map
240
249
  //# 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) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[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\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 /** 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 = \"1\";\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 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)}\" style=\"display:block;margin:0 auto;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) ? \"#c1c2d1\" : \"#4a4d63\";\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;\">&nbsp;</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 = \"#4a4d63\";\nconst SIGNOFF_META_DARK = \"#c1c2d1\";\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;opacity:0.65;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. Unknown tokens are left as-is. */\nexport function fill(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) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[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;\">&nbsp;</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 &amp; Søn\" ✓\n * fill THEN render \"Sørensen & Søn\" -> \"Sørensen &amp;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.4.0",
3
+ "version": "0.6.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",