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