@broberg/mail-core 0.1.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 ADDED
@@ -0,0 +1,52 @@
1
+ # @broberg/mail-core
2
+
3
+ Branded HTML email **shell + primitives** for the broberg.ai fleet — layer 1 (visual structure) of the mail stack. Generalized from sanneandersen's hand-rolled shell, cross-checked against xrt81/cardmem/cms's own branded templates.
4
+
5
+ ```bash
6
+ npm i @broberg/mail-core # exact-pin for prod-auth deps
7
+ ```
8
+
9
+ **Not this package:** sending (→ `@broberg/mail`) or template-content/per-tenant override resolution (→ `@broberg/mail-templates`, F040). This package only turns brand params + body HTML into a complete, email-client-safe document.
10
+
11
+ ```ts
12
+ import { renderShell, heading, paragraph, cta, factBox, signOff, makeLogoAttachment } from "@broberg/mail-core";
13
+
14
+ const html = renderShell({
15
+ accentColor: "#2E6B62", // required — no fleet-wide default, nothing silently branded
16
+ cardBg: "#FAF9F6", // optional, default "#ffffff"
17
+ subject: "Welcome to X",
18
+ preheader: "Glad to have you",
19
+ logoUrl: "cid:logo", // pair with makeLogoAttachment for inline CID logos
20
+ bodyHtml: [
21
+ heading("Welcome!"),
22
+ paragraph("Thanks for signing up."),
23
+ factBox([{ label: "Plan", value: "Pro" }, { label: "Started", value: "2026-07-02" }]),
24
+ cta("https://example.com/start", "Get started", { accentColor: "#2E6B62" }),
25
+ signOff("Talk soon,", "The team", "— X"),
26
+ ].join(""),
27
+ footerLines: ["Acme Inc · Some Street 1"],
28
+ });
29
+ ```
30
+
31
+ ## Why `accentColor` is required, not defaulted
32
+
33
+ Every other color has a sensible neutral default (`cardBg` white, `textColor` auto-derived for contrast against `cardBg`, `backdropColor` light grey) — but `accentColor` doesn't, on purpose. A default accent color IS a brand choice; shipping one would silently brand every consumer that forgets to set it. Pass your product's own accent explicitly.
34
+
35
+ ## Dark cards work
36
+
37
+ `textColor` auto-derives from `cardBg`'s perceived luminance — a dark `cardBg` (e.g. `#1a1a1a`) gets light text by default, no manual `textColor` needed (though you can still override it).
38
+
39
+ ## Email-client compatibility
40
+
41
+ Table-based layout throughout (`role="presentation"` tables, not flex/grid), inline styles, `prefers-color-scheme` + Outlook.com's `[data-ogsc]` dark-mode-inversion guards. `cta()` renders a table-cell button, never a bare `<a>`/`<button>` some clients strip styling from.
42
+
43
+ ## Logo attachments
44
+
45
+ `makeLogoAttachment(filePath)` reads a file from a **caller-supplied full path** (never assumes `process.cwd()`) and returns a Resend-shaped inline (CID) attachment — or `null` if the file is missing, so a missing logo degrades gracefully instead of breaking a transactional send.
46
+
47
+ ```ts
48
+ const attachment = makeLogoAttachment("/app/public/brand/logo.png");
49
+ if (attachment) {
50
+ await resend.emails.send({ ..., attachments: [attachment] });
51
+ }
52
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ var fs = require('fs');
4
+
5
+ // src/index.ts
6
+ function escapeHtml(s) {
7
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
8
+ }
9
+ function escapeAttr(s) {
10
+ return escapeHtml(s);
11
+ }
12
+ function isDark(hex) {
13
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
14
+ if (!m) return false;
15
+ const n = parseInt(m[1], 16);
16
+ const r = n >> 16 & 255, g = n >> 8 & 255, b = n & 255;
17
+ return (r * 299 + g * 587 + b * 114) / 1e3 < 128;
18
+ }
19
+ function resolveColors(b) {
20
+ const cardBg = b.cardBg ?? "#ffffff";
21
+ const textColor = b.textColor ?? (isDark(cardBg) ? "#f5f5f5" : "#1a1a1a");
22
+ const backdropColor = b.backdropColor ?? "#f4f4f5";
23
+ const fontSans = b.fontSans ?? "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif";
24
+ const fontSerif = b.fontSerif ?? "Georgia,'Times New Roman',serif";
25
+ return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };
26
+ }
27
+ function renderShell(opts) {
28
+ const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);
29
+ const lang = opts.lang ?? "en";
30
+ const showFooter = opts.showFooter ?? true;
31
+ const logoBlock = opts.logoUrl ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto 16px;">
32
+ <tr><td>
33
+ <img src="${escapeAttr(opts.logoUrl)}" alt="${escapeAttr(opts.logoAlt ?? "")}" style="display:block;margin:0 auto;max-width:180px;height:auto;border:0;">
34
+ </td></tr>
35
+ </table>` : "";
36
+ const footerBlock = showFooter ? `<tr>
37
+ <td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);">
38
+ ${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;opacity:0.65;">${escapeHtml(l)}</p>`).join("")}
39
+ ${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>` : ""}
40
+ </td>
41
+ </tr>` : "";
42
+ return `<!doctype html>
43
+ <html lang="${escapeAttr(lang)}">
44
+ <head>
45
+ <meta charset="utf-8">
46
+ <meta name="viewport" content="width=device-width,initial-scale=1">
47
+ <meta name="color-scheme" content="light only">
48
+ <meta name="supported-color-schemes" content="light only">
49
+ <title>${escapeHtml(opts.subject)}</title>
50
+ <style>
51
+ :root { color-scheme: light only; supported-color-schemes: light only; }
52
+ @media (prefers-color-scheme: dark) {
53
+ .mc-bg-outer { background:${backdropColor} !important; }
54
+ .mc-bg-card { background:${cardBg} !important; }
55
+ .mc-text { color:${textColor} !important; }
56
+ }
57
+ [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }
58
+ [data-ogsc] .mc-bg-card { background:${cardBg} !important; }
59
+ [data-ogsc] .mc-text { color:${textColor} !important; }
60
+ </style>
61
+ </head>
62
+ <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;">
63
+ ${opts.preheader ? `<div style="display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(opts.preheader)}</div>` : ""}
64
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${backdropColor}" class="mc-bg-outer" style="background:${backdropColor};padding:32px 16px;">
65
+ <tr>
66
+ <td align="center">
67
+ <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);">
68
+ <tr><td bgcolor="${accentColor}" style="background:${accentColor};height:4px;line-height:4px;font-size:0;">&nbsp;</td></tr>
69
+ <tr>
70
+ <td bgcolor="${cardBg}" class="mc-bg-card" style="background:${cardBg};padding:40px 40px 0;text-align:center;">
71
+ ${logoBlock}
72
+ </td>
73
+ </tr>
74
+ <tr>
75
+ <td bgcolor="${cardBg}" class="mc-bg-card mc-text" style="background:${cardBg};padding:32px 40px;">
76
+ ${opts.bodyHtml}
77
+ </td>
78
+ </tr>
79
+ ${footerBlock}
80
+ </table>
81
+ </td>
82
+ </tr>
83
+ </table>
84
+ </body>
85
+ </html>`;
86
+ }
87
+ function heading(text, opts) {
88
+ const fontSerif = opts?.fontSerif ?? "Georgia,'Times New Roman',serif";
89
+ const textColor = opts?.textColor ?? "#1a1a1a";
90
+ return `<h1 style="margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;">${escapeHtml(text)}</h1>`;
91
+ }
92
+ function paragraph(text) {
93
+ return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${escapeHtml(text)}</p>`;
94
+ }
95
+ function paragraphHtml(html) {
96
+ return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${html}</p>`;
97
+ }
98
+ function signOff(line1, line2, sign) {
99
+ return `<div style="margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;">
100
+ <p style="margin:0;font-size:15px;line-height:1.8;">
101
+ ${escapeHtml(line1)}<br>
102
+ ${escapeHtml(line2)}<br>
103
+ <span style="font-size:20px;">${escapeHtml(sign)}</span>
104
+ </p>
105
+ </div>`;
106
+ }
107
+ function cta(href, label, opts) {
108
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:28px auto 8px;">
109
+ <tr>
110
+ <td bgcolor="${opts.accentColor}" style="background:${opts.accentColor};border-radius:999px;">
111
+ <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>
112
+ </td>
113
+ </tr>
114
+ </table>`;
115
+ }
116
+ function factBox(rows, opts) {
117
+ if (rows.length === 0) return "";
118
+ const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : "border:1px solid rgba(0,0,0,0.1);";
119
+ const cells = rows.map(
120
+ (r) => `<tr>
121
+ <td style="padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;">${escapeHtml(r.label)}</td>
122
+ <td style="padding:6px 0;font-size:13px;font-weight:600;">${escapeHtml(r.value)}</td>
123
+ </tr>`
124
+ ).join("");
125
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;${border}border-radius:8px;">
126
+ <tr><td style="padding:12px 16px;">
127
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${cells}</table>
128
+ </td></tr>
129
+ </table>`;
130
+ }
131
+ function fill(template, vars) {
132
+ return template.replace(/\{(\w+)\}/g, (_, key) => key in vars ? String(vars[key]) : `{${key}}`);
133
+ }
134
+ function makeLogoAttachment(filePath, opts) {
135
+ if (!fs.existsSync(filePath)) return null;
136
+ try {
137
+ const content = fs.readFileSync(filePath);
138
+ const filename = filePath.split("/").pop() ?? "logo";
139
+ const contentType = opts?.contentType ?? (filename.endsWith(".svg") ? "image/svg+xml" : "image/png");
140
+ return { filename, content, contentId: opts?.contentId ?? "logo", contentType };
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ exports.cta = cta;
147
+ exports.escapeAttr = escapeAttr;
148
+ exports.escapeHtml = escapeHtml;
149
+ exports.factBox = factBox;
150
+ exports.fill = fill;
151
+ exports.heading = heading;
152
+ exports.makeLogoAttachment = makeLogoAttachment;
153
+ exports.paragraph = paragraph;
154
+ exports.paragraphHtml = paragraphHtml;
155
+ exports.renderShell = renderShell;
156
+ exports.signOff = signOff;
157
+ //# sourceMappingURL=index.cjs.map
158
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":["existsSync","readFileSync"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n const cardBg = b.cardBg ?? \"#ffffff\";\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 hosted URL. */\n logoUrl?: string;\n logoAlt?: string;\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. */\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 logoBlock = opts.logoUrl\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(opts.logoUrl)}\" alt=\"${escapeAttr(opts.logoAlt ?? \"\")}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;opacity:0.65;\">${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<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 :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\">&nbsp;</td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\nexport function heading(text: string, opts?: { fontSerif?: string; textColor?: string }): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${escapeHtml(text)}</h1>`;\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\nexport function signOff(line1: string, line2: string, sign: string): string {\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 ${escapeHtml(line1)}<br>\n ${escapeHtml(line2)}<br>\n <span style=\"font-size:20px;\">${escapeHtml(sign)}</span>\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Branded HTML email shell + primitives — layer 1 (visual structure) of the
3
+ * fleet's mail stack. No sending (that's @broberg/mail) and no template
4
+ * content/override-resolution (that's @broberg/mail-templates, F040) — this
5
+ * package only turns brand params + body HTML into a complete, email-client-
6
+ * safe HTML document, plus the small block builders every template needs.
7
+ *
8
+ * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table
9
+ * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/
10
+ * copy value that file hardcoded is now a caller-supplied option.
11
+ */
12
+ declare function escapeHtml(s: string): string;
13
+ declare function escapeAttr(s: string): string;
14
+ interface BrandColors {
15
+ /** Top-of-card accent + CTA button color. Required — no fleet-wide default,
16
+ * so nothing is silently branded as some other product's identity. */
17
+ accentColor: string;
18
+ /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')
19
+ * for a dark-card brand; textColor's default adapts automatically. */
20
+ cardBg?: string;
21
+ /** Body text color. Default derived from cardBg (light card → dark text,
22
+ * dark card → light text) so a dark-card brand isn't illegible by default. */
23
+ textColor?: string;
24
+ /** Page background behind the card. Default '#f4f4f5'. */
25
+ backdropColor?: string;
26
+ fontSans?: string;
27
+ fontSerif?: string;
28
+ }
29
+ interface ShellOpts extends BrandColors {
30
+ subject: string;
31
+ /** Hidden preview text shown in the mail-client inbox list. */
32
+ preheader?: string;
33
+ lang?: string;
34
+ /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */
35
+ bodyHtml: string;
36
+ showFooter?: boolean;
37
+ footerLines?: string[];
38
+ footerHref?: string;
39
+ footerLabel?: string;
40
+ /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a hosted URL. */
41
+ logoUrl?: string;
42
+ logoAlt?: string;
43
+ }
44
+ /** Renders a complete, email-client-safe HTML document: table layout (not
45
+ * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via
46
+ * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded
47
+ * card with an accent-colored top strip, and an optional footer. */
48
+ declare function renderShell(opts: ShellOpts): string;
49
+ declare function heading(text: string, opts?: {
50
+ fontSerif?: string;
51
+ textColor?: string;
52
+ }): string;
53
+ declare function paragraph(text: string): string;
54
+ /** Like paragraph(), but the string is injected as raw HTML (not escaped) —
55
+ * the caller must escapeHtml() any dynamic values themselves. */
56
+ declare function paragraphHtml(html: string): string;
57
+ declare function signOff(line1: string, line2: string, sign: string): string;
58
+ /** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action
59
+ * button — the pattern every surveyed template hand-rolled per-brand. */
60
+ declare function cta(href: string, label: string, opts: {
61
+ accentColor: string;
62
+ }): string;
63
+ interface FactRow {
64
+ label: string;
65
+ value: string;
66
+ }
67
+ /** A structured label/value block (table rows, not flex/grid — email-client
68
+ * safe) for rendering e.g. booking details or submitted form fields. */
69
+ declare function factBox(rows: FactRow[], opts?: {
70
+ accentColor?: string;
71
+ }): string;
72
+ /** Replace {token} placeholders with values. Unknown tokens are left as-is. */
73
+ declare function fill(template: string, vars: Record<string, string | number>): string;
74
+ interface MailAttachment {
75
+ filename: string;
76
+ content: Buffer;
77
+ contentId: string;
78
+ contentType: string;
79
+ }
80
+ /** Reads a logo file from a caller-supplied full path and returns a
81
+ * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —
82
+ * never throws, so a missing logo degrades to no-logo, not a broken send. */
83
+ declare function makeLogoAttachment(filePath: string, opts?: {
84
+ contentId?: string;
85
+ contentType?: string;
86
+ }): MailAttachment | null;
87
+
88
+ export { type BrandColors, type FactRow, type MailAttachment, type ShellOpts, cta, escapeAttr, escapeHtml, factBox, fill, heading, makeLogoAttachment, paragraph, paragraphHtml, renderShell, signOff };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Branded HTML email shell + primitives — layer 1 (visual structure) of the
3
+ * fleet's mail stack. No sending (that's @broberg/mail) and no template
4
+ * content/override-resolution (that's @broberg/mail-templates, F040) — this
5
+ * package only turns brand params + body HTML into a complete, email-client-
6
+ * safe HTML document, plus the small block builders every template needs.
7
+ *
8
+ * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table
9
+ * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/
10
+ * copy value that file hardcoded is now a caller-supplied option.
11
+ */
12
+ declare function escapeHtml(s: string): string;
13
+ declare function escapeAttr(s: string): string;
14
+ interface BrandColors {
15
+ /** Top-of-card accent + CTA button color. Required — no fleet-wide default,
16
+ * so nothing is silently branded as some other product's identity. */
17
+ accentColor: string;
18
+ /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')
19
+ * for a dark-card brand; textColor's default adapts automatically. */
20
+ cardBg?: string;
21
+ /** Body text color. Default derived from cardBg (light card → dark text,
22
+ * dark card → light text) so a dark-card brand isn't illegible by default. */
23
+ textColor?: string;
24
+ /** Page background behind the card. Default '#f4f4f5'. */
25
+ backdropColor?: string;
26
+ fontSans?: string;
27
+ fontSerif?: string;
28
+ }
29
+ interface ShellOpts extends BrandColors {
30
+ subject: string;
31
+ /** Hidden preview text shown in the mail-client inbox list. */
32
+ preheader?: string;
33
+ lang?: string;
34
+ /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */
35
+ bodyHtml: string;
36
+ showFooter?: boolean;
37
+ footerLines?: string[];
38
+ footerHref?: string;
39
+ footerLabel?: string;
40
+ /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a hosted URL. */
41
+ logoUrl?: string;
42
+ logoAlt?: string;
43
+ }
44
+ /** Renders a complete, email-client-safe HTML document: table layout (not
45
+ * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via
46
+ * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded
47
+ * card with an accent-colored top strip, and an optional footer. */
48
+ declare function renderShell(opts: ShellOpts): string;
49
+ declare function heading(text: string, opts?: {
50
+ fontSerif?: string;
51
+ textColor?: string;
52
+ }): string;
53
+ declare function paragraph(text: string): string;
54
+ /** Like paragraph(), but the string is injected as raw HTML (not escaped) —
55
+ * the caller must escapeHtml() any dynamic values themselves. */
56
+ declare function paragraphHtml(html: string): string;
57
+ declare function signOff(line1: string, line2: string, sign: string): string;
58
+ /** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action
59
+ * button — the pattern every surveyed template hand-rolled per-brand. */
60
+ declare function cta(href: string, label: string, opts: {
61
+ accentColor: string;
62
+ }): string;
63
+ interface FactRow {
64
+ label: string;
65
+ value: string;
66
+ }
67
+ /** A structured label/value block (table rows, not flex/grid — email-client
68
+ * safe) for rendering e.g. booking details or submitted form fields. */
69
+ declare function factBox(rows: FactRow[], opts?: {
70
+ accentColor?: string;
71
+ }): string;
72
+ /** Replace {token} placeholders with values. Unknown tokens are left as-is. */
73
+ declare function fill(template: string, vars: Record<string, string | number>): string;
74
+ interface MailAttachment {
75
+ filename: string;
76
+ content: Buffer;
77
+ contentId: string;
78
+ contentType: string;
79
+ }
80
+ /** Reads a logo file from a caller-supplied full path and returns a
81
+ * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —
82
+ * never throws, so a missing logo degrades to no-logo, not a broken send. */
83
+ declare function makeLogoAttachment(filePath: string, opts?: {
84
+ contentId?: string;
85
+ contentType?: string;
86
+ }): MailAttachment | null;
87
+
88
+ export { type BrandColors, type FactRow, type MailAttachment, type ShellOpts, cta, escapeAttr, escapeHtml, factBox, fill, heading, makeLogoAttachment, paragraph, paragraphHtml, renderShell, signOff };
package/dist/index.js ADDED
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+
3
+ // src/index.ts
4
+ function escapeHtml(s) {
5
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
6
+ }
7
+ function escapeAttr(s) {
8
+ return escapeHtml(s);
9
+ }
10
+ function isDark(hex) {
11
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
12
+ if (!m) return false;
13
+ const n = parseInt(m[1], 16);
14
+ const r = n >> 16 & 255, g = n >> 8 & 255, b = n & 255;
15
+ return (r * 299 + g * 587 + b * 114) / 1e3 < 128;
16
+ }
17
+ function resolveColors(b) {
18
+ const cardBg = b.cardBg ?? "#ffffff";
19
+ const textColor = b.textColor ?? (isDark(cardBg) ? "#f5f5f5" : "#1a1a1a");
20
+ const backdropColor = b.backdropColor ?? "#f4f4f5";
21
+ const fontSans = b.fontSans ?? "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif";
22
+ const fontSerif = b.fontSerif ?? "Georgia,'Times New Roman',serif";
23
+ return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };
24
+ }
25
+ function renderShell(opts) {
26
+ const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);
27
+ const lang = opts.lang ?? "en";
28
+ const showFooter = opts.showFooter ?? true;
29
+ const logoBlock = opts.logoUrl ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto 16px;">
30
+ <tr><td>
31
+ <img src="${escapeAttr(opts.logoUrl)}" alt="${escapeAttr(opts.logoAlt ?? "")}" style="display:block;margin:0 auto;max-width:180px;height:auto;border:0;">
32
+ </td></tr>
33
+ </table>` : "";
34
+ const footerBlock = showFooter ? `<tr>
35
+ <td bgcolor="${backdropColor}" style="background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);">
36
+ ${(opts.footerLines ?? []).map((l) => `<p style="margin:0 0 4px;font-size:11px;opacity:0.65;">${escapeHtml(l)}</p>`).join("")}
37
+ ${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>` : ""}
38
+ </td>
39
+ </tr>` : "";
40
+ return `<!doctype html>
41
+ <html lang="${escapeAttr(lang)}">
42
+ <head>
43
+ <meta charset="utf-8">
44
+ <meta name="viewport" content="width=device-width,initial-scale=1">
45
+ <meta name="color-scheme" content="light only">
46
+ <meta name="supported-color-schemes" content="light only">
47
+ <title>${escapeHtml(opts.subject)}</title>
48
+ <style>
49
+ :root { color-scheme: light only; supported-color-schemes: light only; }
50
+ @media (prefers-color-scheme: dark) {
51
+ .mc-bg-outer { background:${backdropColor} !important; }
52
+ .mc-bg-card { background:${cardBg} !important; }
53
+ .mc-text { color:${textColor} !important; }
54
+ }
55
+ [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }
56
+ [data-ogsc] .mc-bg-card { background:${cardBg} !important; }
57
+ [data-ogsc] .mc-text { color:${textColor} !important; }
58
+ </style>
59
+ </head>
60
+ <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;">
61
+ ${opts.preheader ? `<div style="display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(opts.preheader)}</div>` : ""}
62
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${backdropColor}" class="mc-bg-outer" style="background:${backdropColor};padding:32px 16px;">
63
+ <tr>
64
+ <td align="center">
65
+ <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);">
66
+ <tr><td bgcolor="${accentColor}" style="background:${accentColor};height:4px;line-height:4px;font-size:0;">&nbsp;</td></tr>
67
+ <tr>
68
+ <td bgcolor="${cardBg}" class="mc-bg-card" style="background:${cardBg};padding:40px 40px 0;text-align:center;">
69
+ ${logoBlock}
70
+ </td>
71
+ </tr>
72
+ <tr>
73
+ <td bgcolor="${cardBg}" class="mc-bg-card mc-text" style="background:${cardBg};padding:32px 40px;">
74
+ ${opts.bodyHtml}
75
+ </td>
76
+ </tr>
77
+ ${footerBlock}
78
+ </table>
79
+ </td>
80
+ </tr>
81
+ </table>
82
+ </body>
83
+ </html>`;
84
+ }
85
+ function heading(text, opts) {
86
+ const fontSerif = opts?.fontSerif ?? "Georgia,'Times New Roman',serif";
87
+ const textColor = opts?.textColor ?? "#1a1a1a";
88
+ return `<h1 style="margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;">${escapeHtml(text)}</h1>`;
89
+ }
90
+ function paragraph(text) {
91
+ return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${escapeHtml(text)}</p>`;
92
+ }
93
+ function paragraphHtml(html) {
94
+ return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${html}</p>`;
95
+ }
96
+ function signOff(line1, line2, sign) {
97
+ return `<div style="margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;">
98
+ <p style="margin:0;font-size:15px;line-height:1.8;">
99
+ ${escapeHtml(line1)}<br>
100
+ ${escapeHtml(line2)}<br>
101
+ <span style="font-size:20px;">${escapeHtml(sign)}</span>
102
+ </p>
103
+ </div>`;
104
+ }
105
+ function cta(href, label, opts) {
106
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:28px auto 8px;">
107
+ <tr>
108
+ <td bgcolor="${opts.accentColor}" style="background:${opts.accentColor};border-radius:999px;">
109
+ <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>
110
+ </td>
111
+ </tr>
112
+ </table>`;
113
+ }
114
+ function factBox(rows, opts) {
115
+ if (rows.length === 0) return "";
116
+ const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : "border:1px solid rgba(0,0,0,0.1);";
117
+ const cells = rows.map(
118
+ (r) => `<tr>
119
+ <td style="padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;">${escapeHtml(r.label)}</td>
120
+ <td style="padding:6px 0;font-size:13px;font-weight:600;">${escapeHtml(r.value)}</td>
121
+ </tr>`
122
+ ).join("");
123
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;${border}border-radius:8px;">
124
+ <tr><td style="padding:12px 16px;">
125
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${cells}</table>
126
+ </td></tr>
127
+ </table>`;
128
+ }
129
+ function fill(template, vars) {
130
+ return template.replace(/\{(\w+)\}/g, (_, key) => key in vars ? String(vars[key]) : `{${key}}`);
131
+ }
132
+ function makeLogoAttachment(filePath, opts) {
133
+ if (!existsSync(filePath)) return null;
134
+ try {
135
+ const content = readFileSync(filePath);
136
+ const filename = filePath.split("/").pop() ?? "logo";
137
+ const contentType = opts?.contentType ?? (filename.endsWith(".svg") ? "image/svg+xml" : "image/png");
138
+ return { filename, content, contentId: opts?.contentId ?? "logo", contentType };
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ export { cta, escapeAttr, escapeHtml, factBox, fill, heading, makeLogoAttachment, paragraph, paragraphHtml, renderShell, signOff };
145
+ //# sourceMappingURL=index.js.map
146
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n const cardBg = b.cardBg ?? \"#ffffff\";\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 hosted URL. */\n logoUrl?: string;\n logoAlt?: string;\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. */\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 logoBlock = opts.logoUrl\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(opts.logoUrl)}\" alt=\"${escapeAttr(opts.logoAlt ?? \"\")}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;opacity:0.65;\">${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<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 :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\">&nbsp;</td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\nexport function heading(text: string, opts?: { fontSerif?: string; textColor?: string }): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${escapeHtml(text)}</h1>`;\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\nexport function signOff(line1: string, line2: string, sign: string): string {\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 ${escapeHtml(line1)}<br>\n ${escapeHtml(line2)}<br>\n <span style=\"font-size:20px;\">${escapeHtml(sign)}</span>\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@broberg/mail-core",
3
+ "version": "0.1.0",
4
+ "description": "Branded HTML email shell + primitives for the broberg.ai fleet — renderShell, heading/paragraph/cta/factBox/signOff, and a CID logo-attachment helper. No sending (@broberg/mail) and no template-content/override-resolution (@broberg/mail-templates) — layer 1 (visual structure) only. Every brand value is a caller-supplied param.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "files": ["dist", "README.md"],
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "test": "vitest run",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.7.0",
26
+ "tsup": "^8.3.0",
27
+ "typescript": "^5.6.0",
28
+ "vitest": "^2.1.0"
29
+ },
30
+ "keywords": [
31
+ "email",
32
+ "mail",
33
+ "html-email",
34
+ "email-template",
35
+ "transactional-email",
36
+ "email-shell",
37
+ "broberg"
38
+ ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/broberg-ai/components",
42
+ "directory": "packages/mail-core"
43
+ },
44
+ "publishConfig": { "access": "public" }
45
+ }