@broberg/mail-core 0.3.1 → 0.4.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 +25 -0
- package/dist/index.cjs +36 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +35 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -107,3 +107,28 @@ than our variant."* Recorded so the next consumer does not have to ask.
|
|
|
107
107
|
**the mail carries the SENDER's identity, and the sender is not always the repo
|
|
108
108
|
the template lives in.** A shared shell with per-send branding is the point; a
|
|
109
109
|
shell with the branding baked in is a different, worse product.
|
|
110
|
+
|
|
111
|
+
## Brand values are validated, and an invalid one throws
|
|
112
|
+
|
|
113
|
+
`accentColor`, `cardBg`, `textColor` and `backdropColor` must be a CSS colour —
|
|
114
|
+
hex (`#rgb`, `#rrggbb`, `#rrggbbaa`), `rgb()`/`rgba()`, `hsl()`/`hsla()`, or a
|
|
115
|
+
named colour (all 148, `rebeccapurple` included). `fontSans` / `fontSerif` must
|
|
116
|
+
not contain `< > "` or a backtick. Anything else **throws**, naming the field.
|
|
117
|
+
|
|
118
|
+
**It rejects rather than escapes, deliberately.** An escaped non-colour still
|
|
119
|
+
leaves the building and renders as literal garbage inside a `style` attribute —
|
|
120
|
+
the customer sees a broken mail and nobody sees an error. Throwing fails at the
|
|
121
|
+
caller, where someone can act on it.
|
|
122
|
+
|
|
123
|
+
This matters if you resolve branding **per tenant from a database**. These values
|
|
124
|
+
are interpolated into HTML attributes, so before the guard existed an accent
|
|
125
|
+
colour of
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
#fff;"></td></tr></table><a href="https://phish.example">Log ind her</a><table><tr><td x="
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
put that anchor into the rendered mail. No script is involved — a login link
|
|
132
|
+
inside an otherwise genuine, correctly-branded transactional mail is the whole
|
|
133
|
+
attack, and clients that strip script still render it. Validate at your own
|
|
134
|
+
boundary too; this is the last line, not the only one.
|
package/dist/index.cjs
CHANGED
|
@@ -9,6 +9,26 @@ function escapeHtml(s) {
|
|
|
9
9
|
function escapeAttr(s) {
|
|
10
10
|
return escapeHtml(s);
|
|
11
11
|
}
|
|
12
|
+
var NAMED_COLORS = new Set(
|
|
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
|
+
);
|
|
15
|
+
var HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
16
|
+
var FUNCTIONAL = /^(?:rgb|rgba|hsl|hsla)\(\s*[0-9a-z.%,\s/+-]+\)$/i;
|
|
17
|
+
function assertColor(field, value) {
|
|
18
|
+
if (value === void 0) return;
|
|
19
|
+
const v = value.trim();
|
|
20
|
+
if (HEX.test(v) || FUNCTIONAL.test(v) || NAMED_COLORS.has(v.toLowerCase())) return;
|
|
21
|
+
throw new Error(
|
|
22
|
+
`@broberg/mail-core: ${field} is not a CSS colour (received ${JSON.stringify(value)}). Brand values are interpolated into HTML attributes, so an arbitrary string here can inject markup into the mail. Pass a hex, rgb()/rgba(), hsl()/hsla(), or a named colour.`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
function assertFontStack(field, value) {
|
|
26
|
+
if (value === void 0) return;
|
|
27
|
+
if (!/[<>"`]/.test(value)) return;
|
|
28
|
+
throw new Error(
|
|
29
|
+
`@broberg/mail-core: ${field} contains a character that can break out of the attribute it is rendered into (received ${JSON.stringify(value)}). Use single quotes for family names: "-apple-system,'Segoe UI',sans-serif".`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
12
32
|
function isDark(hex) {
|
|
13
33
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
14
34
|
if (!m) return false;
|
|
@@ -17,6 +37,12 @@ function isDark(hex) {
|
|
|
17
37
|
return (r * 299 + g * 587 + b * 114) / 1e3 < 128;
|
|
18
38
|
}
|
|
19
39
|
function resolveColors(b) {
|
|
40
|
+
assertColor("accentColor", b.accentColor);
|
|
41
|
+
assertColor("cardBg", b.cardBg);
|
|
42
|
+
assertColor("textColor", b.textColor);
|
|
43
|
+
assertColor("backdropColor", b.backdropColor);
|
|
44
|
+
assertFontStack("fontSans", b.fontSans);
|
|
45
|
+
assertFontStack("fontSerif", b.fontSerif);
|
|
20
46
|
const cardBg = b.cardBg ?? "#fffffe";
|
|
21
47
|
const textColor = b.textColor ?? (isDark(cardBg) ? "#f5f5f5" : "#1a1a1a");
|
|
22
48
|
const backdropColor = b.backdropColor ?? "#f4f4f5";
|
|
@@ -118,6 +144,9 @@ ${opts.preheader ? `<div style="display:none;font-size:1px;max-height:0;overflow
|
|
|
118
144
|
</html>`;
|
|
119
145
|
}
|
|
120
146
|
function heading(text, opts) {
|
|
147
|
+
assertColor("accentColor", opts?.accentColor);
|
|
148
|
+
assertColor("textColor", opts?.textColor);
|
|
149
|
+
assertFontStack("fontSerif", opts?.fontSerif);
|
|
121
150
|
const fontSerif = opts?.fontSerif ?? "Georgia,'Times New Roman',serif";
|
|
122
151
|
const textColor = opts?.textColor ?? "#1a1a1a";
|
|
123
152
|
let inner = escapeHtml(text);
|
|
@@ -133,9 +162,11 @@ function heading(text, opts) {
|
|
|
133
162
|
return `<h1 style="margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;">${inner}</h1>`;
|
|
134
163
|
}
|
|
135
164
|
function eyebrow(text, opts) {
|
|
165
|
+
assertColor("accentColor", opts.accentColor);
|
|
136
166
|
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>`;
|
|
137
167
|
}
|
|
138
168
|
function noteBox(html, opts) {
|
|
169
|
+
assertColor("accentColor", opts.accentColor);
|
|
139
170
|
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;">
|
|
140
171
|
<tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;">${html}</td></tr>
|
|
141
172
|
</table>`;
|
|
@@ -155,6 +186,7 @@ function signOffLine(line, metaColor) {
|
|
|
155
186
|
return text;
|
|
156
187
|
}
|
|
157
188
|
function signOff(a, b, sign) {
|
|
189
|
+
if (Array.isArray(a) && typeof b === "object") assertColor("cardBg", b?.cardBg);
|
|
158
190
|
const br = "<br>\n ";
|
|
159
191
|
const metaColor = Array.isArray(a) && typeof b === "object" && b?.cardBg && isDark(b.cardBg) ? SIGNOFF_META_DARK : SIGNOFF_META_LIGHT;
|
|
160
192
|
const body = Array.isArray(a) ? a.map((l) => signOffLine(l, metaColor)).join(br) : [escapeHtml(a), escapeHtml(typeof b === "string" ? b : "")].join(br) + (sign ? `${br}<span style="font-size:20px;">${escapeHtml(sign)}</span>` : "");
|
|
@@ -165,6 +197,7 @@ function signOff(a, b, sign) {
|
|
|
165
197
|
</div>`;
|
|
166
198
|
}
|
|
167
199
|
function cta(href, label, opts) {
|
|
200
|
+
assertColor("accentColor", opts.accentColor);
|
|
168
201
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:28px auto 8px;">
|
|
169
202
|
<tr>
|
|
170
203
|
<td bgcolor="${opts.accentColor}" style="background:${opts.accentColor};border-radius:999px;">
|
|
@@ -174,6 +207,7 @@ function cta(href, label, opts) {
|
|
|
174
207
|
</table>`;
|
|
175
208
|
}
|
|
176
209
|
function factBox(rows, opts) {
|
|
210
|
+
assertColor("accentColor", opts?.accentColor);
|
|
177
211
|
if (rows.length === 0) return "";
|
|
178
212
|
const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : "border:1px solid rgba(0,0,0,0.1);";
|
|
179
213
|
const cells = rows.map(
|
|
@@ -204,6 +238,8 @@ function makeLogoAttachment(filePath, opts) {
|
|
|
204
238
|
}
|
|
205
239
|
|
|
206
240
|
exports.SHELL_VERSION = SHELL_VERSION;
|
|
241
|
+
exports.assertColor = assertColor;
|
|
242
|
+
exports.assertFontStack = assertFontStack;
|
|
207
243
|
exports.cta = cta;
|
|
208
244
|
exports.escapeAttr = escapeAttr;
|
|
209
245
|
exports.escapeHtml = escapeHtml;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["existsSync","readFileSync"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#fffffe' — one byte off white on purpose, so a\n * client looking for EXACTLY #ffffff does not decide the mail wants\n * inverting. Pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\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 // #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;\"> </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 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 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 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 // 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 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 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) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[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;\"> </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"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -28,6 +28,30 @@ interface BrandColors {
|
|
|
28
28
|
fontSans?: string;
|
|
29
29
|
fontSerif?: string;
|
|
30
30
|
}
|
|
31
|
+
/** Reject a brand colour that is not a colour. **REJECT, never escape** — an
|
|
32
|
+
* escaped non-colour still leaves the building and still renders as literal
|
|
33
|
+
* garbage inside a `style` attribute, so the customer sees a broken mail and
|
|
34
|
+
* nobody sees an error. Throwing fails at the CALLER, where someone can act.
|
|
35
|
+
*
|
|
36
|
+
* PROVEN REACHABLE, 2026-09-03, against the built package (F023.9):
|
|
37
|
+
* accentColor = '#0f7391" onmouseover="alert(1)" x="'
|
|
38
|
+
* -> <td bgcolor="#0f7391" onmouseover="alert(1)" x="" ...>
|
|
39
|
+
* a longer payload injected a complete
|
|
40
|
+
* <a href="https://phish.example">Log ind her</a>
|
|
41
|
+
* into the rendered mail. No script needed: a login link inside an otherwise
|
|
42
|
+
* genuine, correctly-branded transactional mail IS the attack, and clients
|
|
43
|
+
* that strip script still render the anchor.
|
|
44
|
+
*
|
|
45
|
+
* It was not reachable when this was written — a single-tenant repo passes a
|
|
46
|
+
* constant from a config file and has no attacker. xrt81 now resolves branding
|
|
47
|
+
* PER TENANT from a database and cardmem's template store is being built. The
|
|
48
|
+
* assumption did not become false through carelessness; the deployment model
|
|
49
|
+
* moved underneath it. */
|
|
50
|
+
declare function assertColor(field: string, value: string | undefined): void;
|
|
51
|
+
/** A font stack is NOT a colour and must not borrow the colour grammar — it
|
|
52
|
+
* legitimately contains quotes and commas (`'Segoe UI'`). What cannot appear is
|
|
53
|
+
* a tag delimiter or a quote that closes the attribute we sit inside. */
|
|
54
|
+
declare function assertFontStack(field: string, value: string | undefined): void;
|
|
31
55
|
interface ShellOpts extends BrandColors {
|
|
32
56
|
subject: string;
|
|
33
57
|
/** Hidden preview text shown in the mail-client inbox list. */
|
|
@@ -214,4 +238,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
|
|
|
214
238
|
contentType?: string;
|
|
215
239
|
}): MailAttachment | null;
|
|
216
240
|
|
|
217
|
-
export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
|
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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,30 @@ interface BrandColors {
|
|
|
28
28
|
fontSans?: string;
|
|
29
29
|
fontSerif?: string;
|
|
30
30
|
}
|
|
31
|
+
/** Reject a brand colour that is not a colour. **REJECT, never escape** — an
|
|
32
|
+
* escaped non-colour still leaves the building and still renders as literal
|
|
33
|
+
* garbage inside a `style` attribute, so the customer sees a broken mail and
|
|
34
|
+
* nobody sees an error. Throwing fails at the CALLER, where someone can act.
|
|
35
|
+
*
|
|
36
|
+
* PROVEN REACHABLE, 2026-09-03, against the built package (F023.9):
|
|
37
|
+
* accentColor = '#0f7391" onmouseover="alert(1)" x="'
|
|
38
|
+
* -> <td bgcolor="#0f7391" onmouseover="alert(1)" x="" ...>
|
|
39
|
+
* a longer payload injected a complete
|
|
40
|
+
* <a href="https://phish.example">Log ind her</a>
|
|
41
|
+
* into the rendered mail. No script needed: a login link inside an otherwise
|
|
42
|
+
* genuine, correctly-branded transactional mail IS the attack, and clients
|
|
43
|
+
* that strip script still render the anchor.
|
|
44
|
+
*
|
|
45
|
+
* It was not reachable when this was written — a single-tenant repo passes a
|
|
46
|
+
* constant from a config file and has no attacker. xrt81 now resolves branding
|
|
47
|
+
* PER TENANT from a database and cardmem's template store is being built. The
|
|
48
|
+
* assumption did not become false through carelessness; the deployment model
|
|
49
|
+
* moved underneath it. */
|
|
50
|
+
declare function assertColor(field: string, value: string | undefined): void;
|
|
51
|
+
/** A font stack is NOT a colour and must not borrow the colour grammar — it
|
|
52
|
+
* legitimately contains quotes and commas (`'Segoe UI'`). What cannot appear is
|
|
53
|
+
* a tag delimiter or a quote that closes the attribute we sit inside. */
|
|
54
|
+
declare function assertFontStack(field: string, value: string | undefined): void;
|
|
31
55
|
interface ShellOpts extends BrandColors {
|
|
32
56
|
subject: string;
|
|
33
57
|
/** Hidden preview text shown in the mail-client inbox list. */
|
|
@@ -214,4 +238,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
|
|
|
214
238
|
contentType?: string;
|
|
215
239
|
}): MailAttachment | null;
|
|
216
240
|
|
|
217
|
-
export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, type SignOffLine, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,26 @@ function escapeHtml(s) {
|
|
|
7
7
|
function escapeAttr(s) {
|
|
8
8
|
return escapeHtml(s);
|
|
9
9
|
}
|
|
10
|
+
var NAMED_COLORS = new Set(
|
|
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
|
+
);
|
|
13
|
+
var HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
14
|
+
var FUNCTIONAL = /^(?:rgb|rgba|hsl|hsla)\(\s*[0-9a-z.%,\s/+-]+\)$/i;
|
|
15
|
+
function assertColor(field, value) {
|
|
16
|
+
if (value === void 0) return;
|
|
17
|
+
const v = value.trim();
|
|
18
|
+
if (HEX.test(v) || FUNCTIONAL.test(v) || NAMED_COLORS.has(v.toLowerCase())) return;
|
|
19
|
+
throw new Error(
|
|
20
|
+
`@broberg/mail-core: ${field} is not a CSS colour (received ${JSON.stringify(value)}). Brand values are interpolated into HTML attributes, so an arbitrary string here can inject markup into the mail. Pass a hex, rgb()/rgba(), hsl()/hsla(), or a named colour.`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
function assertFontStack(field, value) {
|
|
24
|
+
if (value === void 0) return;
|
|
25
|
+
if (!/[<>"`]/.test(value)) return;
|
|
26
|
+
throw new Error(
|
|
27
|
+
`@broberg/mail-core: ${field} contains a character that can break out of the attribute it is rendered into (received ${JSON.stringify(value)}). Use single quotes for family names: "-apple-system,'Segoe UI',sans-serif".`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
10
30
|
function isDark(hex) {
|
|
11
31
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
12
32
|
if (!m) return false;
|
|
@@ -15,6 +35,12 @@ function isDark(hex) {
|
|
|
15
35
|
return (r * 299 + g * 587 + b * 114) / 1e3 < 128;
|
|
16
36
|
}
|
|
17
37
|
function resolveColors(b) {
|
|
38
|
+
assertColor("accentColor", b.accentColor);
|
|
39
|
+
assertColor("cardBg", b.cardBg);
|
|
40
|
+
assertColor("textColor", b.textColor);
|
|
41
|
+
assertColor("backdropColor", b.backdropColor);
|
|
42
|
+
assertFontStack("fontSans", b.fontSans);
|
|
43
|
+
assertFontStack("fontSerif", b.fontSerif);
|
|
18
44
|
const cardBg = b.cardBg ?? "#fffffe";
|
|
19
45
|
const textColor = b.textColor ?? (isDark(cardBg) ? "#f5f5f5" : "#1a1a1a");
|
|
20
46
|
const backdropColor = b.backdropColor ?? "#f4f4f5";
|
|
@@ -116,6 +142,9 @@ ${opts.preheader ? `<div style="display:none;font-size:1px;max-height:0;overflow
|
|
|
116
142
|
</html>`;
|
|
117
143
|
}
|
|
118
144
|
function heading(text, opts) {
|
|
145
|
+
assertColor("accentColor", opts?.accentColor);
|
|
146
|
+
assertColor("textColor", opts?.textColor);
|
|
147
|
+
assertFontStack("fontSerif", opts?.fontSerif);
|
|
119
148
|
const fontSerif = opts?.fontSerif ?? "Georgia,'Times New Roman',serif";
|
|
120
149
|
const textColor = opts?.textColor ?? "#1a1a1a";
|
|
121
150
|
let inner = escapeHtml(text);
|
|
@@ -131,9 +160,11 @@ function heading(text, opts) {
|
|
|
131
160
|
return `<h1 style="margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;">${inner}</h1>`;
|
|
132
161
|
}
|
|
133
162
|
function eyebrow(text, opts) {
|
|
163
|
+
assertColor("accentColor", opts.accentColor);
|
|
134
164
|
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>`;
|
|
135
165
|
}
|
|
136
166
|
function noteBox(html, opts) {
|
|
167
|
+
assertColor("accentColor", opts.accentColor);
|
|
137
168
|
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;">
|
|
138
169
|
<tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;">${html}</td></tr>
|
|
139
170
|
</table>`;
|
|
@@ -153,6 +184,7 @@ function signOffLine(line, metaColor) {
|
|
|
153
184
|
return text;
|
|
154
185
|
}
|
|
155
186
|
function signOff(a, b, sign) {
|
|
187
|
+
if (Array.isArray(a) && typeof b === "object") assertColor("cardBg", b?.cardBg);
|
|
156
188
|
const br = "<br>\n ";
|
|
157
189
|
const metaColor = Array.isArray(a) && typeof b === "object" && b?.cardBg && isDark(b.cardBg) ? SIGNOFF_META_DARK : SIGNOFF_META_LIGHT;
|
|
158
190
|
const body = Array.isArray(a) ? a.map((l) => signOffLine(l, metaColor)).join(br) : [escapeHtml(a), escapeHtml(typeof b === "string" ? b : "")].join(br) + (sign ? `${br}<span style="font-size:20px;">${escapeHtml(sign)}</span>` : "");
|
|
@@ -163,6 +195,7 @@ function signOff(a, b, sign) {
|
|
|
163
195
|
</div>`;
|
|
164
196
|
}
|
|
165
197
|
function cta(href, label, opts) {
|
|
198
|
+
assertColor("accentColor", opts.accentColor);
|
|
166
199
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:28px auto 8px;">
|
|
167
200
|
<tr>
|
|
168
201
|
<td bgcolor="${opts.accentColor}" style="background:${opts.accentColor};border-radius:999px;">
|
|
@@ -172,6 +205,7 @@ function cta(href, label, opts) {
|
|
|
172
205
|
</table>`;
|
|
173
206
|
}
|
|
174
207
|
function factBox(rows, opts) {
|
|
208
|
+
assertColor("accentColor", opts?.accentColor);
|
|
175
209
|
if (rows.length === 0) return "";
|
|
176
210
|
const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : "border:1px solid rgba(0,0,0,0.1);";
|
|
177
211
|
const cells = rows.map(
|
|
@@ -201,6 +235,6 @@ function makeLogoAttachment(filePath, opts) {
|
|
|
201
235
|
}
|
|
202
236
|
}
|
|
203
237
|
|
|
204
|
-
export { SHELL_VERSION, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
|
238
|
+
export { SHELL_VERSION, assertColor, assertFontStack, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
|
205
239
|
//# sourceMappingURL=index.js.map
|
|
206
240
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#fffffe' — one byte off white on purpose, so a\n * client looking for EXACTLY #ffffff does not decide the mail wants\n * inverting. Pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\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 // #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;\"> </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 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 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 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 // 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 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 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) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[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;\"> </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"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/mail-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|