@broberg/mail-core
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.
npm i @broberg/mail-core # exact-pin for prod-auth deps
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.
import { renderShell, heading, paragraph, cta, factBox, signOff, makeLogoAttachment } from "@broberg/mail-core";
const html = renderShell({
accentColor: "#2E6B62", // required — no fleet-wide default, nothing silently branded
cardBg: "#FAF9F6", // optional, default "#ffffff"
subject: "Welcome to X",
preheader: "Glad to have you",
logoUrl: "cid:logo", // pair with makeLogoAttachment for inline CID logos
bodyHtml: [
heading("Welcome!"),
paragraph("Thanks for signing up."),
factBox([{ label: "Plan", value: "Pro" }, { label: "Started", value: "2026-07-02" }]),
cta("https://example.com/start", "Get started", { accentColor: "#2E6B62" }),
signOff("Talk soon,", "The team", "— X"),
].join(""),
footerLines: ["Acme Inc · Some Street 1"],
});
Code and magic-link mail: codeMail() (since 0.10.0)
One shared mail for a one-time code, optionally with a magic link — so every app gets the same mail and Gmail/iOS get the best chance to offer «Kopiér kode» / autofill on their own. A mail cannot carry a copy button itself (mail clients run no script); the client draws it when it recognises the code.
import { codeMail } from "@broberg/mail-core";
const m = codeMail({ accentColor: "#0f7391", logoWidth: 56, product: "cardmem", code: "064562", minutes: 10,
link: "https://app.example/login?t=…" /* optional */, lang: "da" });
// m.subject "Din kode til cardmem: 064562"
// m.html the shell, with the code as one large monospace line
// m.text plain-text part — send it too; the code stands alone on its own line
// m.warnings e.g. another long number beside the code — log it
await mailer.send({ to, subject: m.subject, html: m.html, text: m.text });
What it does for recognition, and why:
- «kode» and the code in the subject — the inbox reads the subject first.
- The code as TEXT, its characters contiguous. The visible gap between digits is CSS
letter-spacing, never real spaces:0 6 4 5 6 2is no longer one code to a reader. Never an image. - One code per mail. Another long number in the wording is named in
warnings; a code that is not 4–10 letters/digits throws. - A magic link is one button AND the link written out (absolute
https://only, else it throws). - The code inherits the card's text colour, so light and dark cards keep the shell's contrast.
Wording comes from CODE_MAIL_TEXT.da / .en; override any line with text. A subject override must still contain the code. Whether Gmail/iOS actually show the button is measured in real inboxes, not promised here — see F005.25 on the board for what was measured.
Why accentColor is required, not defaulted
Every other color has a sensible neutral default (cardBg #fffffe — see below, 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.
Dark cards work
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).
Email-client compatibility
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.
Outlook iOS in dark mode — measured, and the direction is inverted
Measured by fd-sundhed on a real iPhone running Outlook iOS (2026-08-19):
- Outlook maps a dark source colour to a light rendered one:
#1a1c2b → #c1c2d1,#4a4d63 → #a7a9bf. To make a too-faint line MORE readable at the recipient, make the source colour darker. "Lighten it" is the intuitive fix, and it makes the line worse. The same warning sits onBrandColors.textColor, so your editor shows it where you set the value. - Three force-light techniques had ZERO effect there, not a partial one:
light onlycolor-scheme metas + CSS,[data-ogsc]/[data-ogsb]rules, and#fffffeinstead of#ffffff. Asked for#141969, got#484090. Asked for#fffffe, got#484848, and the card and the footer landed on the same colour, so the footer stopped being a zone at all. They stay in the shell because Apple Mail honours them. Do not add a fourth layer expecting it to fix Outlook. - What actually doubled legibility (2.0:1 → 4.9:1) was structural: no mid-tones, structure from rule-and-space instead of fills, no gradient, and a button with a fill AND a border.
eyebrow(), noteBox() and heading({ emphasis }) exist because vn-leker measured that a consumer had to hand-write them. Every primitive is optional: a mail with three plain lines is a valid mail, so a template built on this must not make a field required just because an example had it.
Fonts: fontSerif and fontSans are fallback STACKS
Pass a stack, never a single family name: "'Playfair Display',Georgia,'Times New Roman',serif". Outlook does not guarantee webfonts, and the answer to that is to LAYER the brand font over safe fallbacks, not to remove it. A single name removes the design for Apple Mail too, the client that can show it.
Logo attachments
This is the RECOMMENDED route for a logo, not an alternative. A mail fetches a hosted logo every time it is opened, for years. If that file ever moves, every mail you have ever sent loses its logo, retroactively. A CID attachment travels inside the mail and cannot break that way. A hosted URL is the fallback (logo: { cid, url } carries both and degrades), and a data: URI is refused because Gmail's image proxy strips it.
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.
const attachment = makeLogoAttachment("/app/public/brand/logo.png");
if (attachment) {
await resend.emails.send({ ..., attachments: [attachment] });
}
Signatures — three tiers, one axis each
signOff([
{ text: "Med venlig hilsen" }, // lead
{ text: "Christian Broberg", tier: "name" }, // + bold
{ text: "CEO & Founding Partner · WebHouse ApS", tier: "meta" }, // + muted colour
]);
Each tier changes exactly one axis against lead — name adds weight,
meta adds a muted colour, and neither touches size. That is an invariant with a
test behind it, not a matter of taste, and it is why there is no fourth tier
coming: there is no fourth axis left to spend.
Two things it deliberately does not do:
nameis bold, not bold-and-darker. On a real palette#1a1c2bis 16.86:1 on white and#0b0e15is 19.29:1 — both so far past every threshold that the step cannot be seen. The weight does the work; the colour shift was decoration.metahas no size of its own. A tier carrying a relative size step turns a 17/17-bold/15 signature into 15/15-bold/13 in a palette with a smaller base, and 13px secondary text is the exact thing one consumer measured their way out of (13.5px#8486a6at 3.5:1, failing WCAG in light mode before anyone mentioned dark). 15px is a measured floor for secondary text in mail.
meta's colour is a real value (#4a4d63, 8.29:1 on white, 7.54:1 on the
default backdrop) and never an opacity. An opacity is not a low contrast
value — it is a contrast value for ONE background. opacity:0.65 of #1a1c2b
measures 5.29:1 while the ground stays white and lands somewhere nobody measured
the moment a client tints or inverts; no contrast tool can read it, because there
is no colour there to read.
The legacy form still works, byte for byte
signOff(line1, line2, sign) renders identically to how it always has — asserted
against a snapshot captured from the shipped build, because repos are calling it
in production mail. It has one fixed axis (size) and its big slot is the last
argument, which is why the array form exists: a name-then-title signature had to
be forced into it, and rendered the job title larger than the person.
The one correction: an empty sign no longer emits a blank line and an empty
<span style="font-size:20px;"></span>.
The centred 180px logo is a deliberate shared choice
renderShell centres the logo at max-width:180px, and that is on purpose
rather than pending. A consumer arrived with their own 40px mark beside the
sender name and dropped it for this one — "one shared expression is worth more
than our variant." Recorded so the next consumer does not have to ask.
But the brand hooks themselves stay caller-supplied. Do not wrap
logoUrl / accentColor / fontSerif in a constant inside a consuming repo:
the mail carries the SENDER's identity, and the sender is not always the repo
the template lives in. A shared shell with per-send branding is the point; a
shell with the branding baked in is a different, worse product.
Brand values are validated, and an invalid one throws
accentColor, cardBg, textColor and backdropColor must be a CSS colour —
hex (#rgb, #rrggbb, #rrggbbaa), rgb()/rgba(), hsl()/hsla(), or a
named colour (all 148, rebeccapurple included). fontSans / fontSerif must
not contain < > " or a backtick. Anything else throws, naming the field.
It rejects rather than escapes, deliberately. An escaped non-colour still
leaves the building and renders as literal garbage inside a style attribute —
the customer sees a broken mail and nobody sees an error. Throwing fails at the
caller, where someone can act on it.
This matters if you resolve branding per tenant from a database. These values are interpolated into HTML attributes, so before the guard existed an accent colour of
#fff;"></td></tr></table><a href="https://phish.example">Log ind her</a><table><tr><td x="
put that anchor into the rendered mail. No script is involved — a login link inside an otherwise genuine, correctly-branded transactional mail is the whole attack, and clients that strip script still render it. Validate at your own boundary too; this is the last line, not the only one.
Loading a webfont: webfontHref (since 0.9.0)
fontSans: "'DM Sans',Arial,sans-serif" names a family. It does not load one. A recipient who does not have DM Sans installed sees the next font in the stack. In Apple Mail, a client that CAN load webfonts, that was Arial.
renderShell({
…,
fontSans: "'DM Sans',-apple-system,'Segoe UI',Arial,sans-serif",
webfontHref: "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600&display=swap",
});
This puts one <link rel="stylesheet"> in <head>, before the shell's <style>.
- Only a Google Fonts stylesheet is accepted. It must be
https://fonts.googleapis.com/cssor/css2on exactly that host, with no port and no credentials. Anything else throws, naming the field. The value lands in an attribute and can come from per-tenant data, so the rule is an allowlist, not escaping (same reasoning as the brand colours above). - Outlook ignores the
<link>, and Gmail strips it. Their recipients get your stack's fallbacks, so keep a real stack. Do not pass a single family name. - Omitted = byte-identical to 0.8.1.
SHELL_VERSIONstays 3.
Sizing the logo — set logoWidth, and set it even when 180 is what you want
renderShell({ …, logoUrl, logoWidth: 56 })
Omit it and you get the historic centred slot unchanged (max-width:180px, no
width attribute) — byte-identical to every mail sent before the field existed.
Set it if you can. A supplied width is emitted as an HTML width attribute
as well as in the style, and the attribute is the only half Outlook reads —
its Word engine ignores CSS dimensions on an image, so without the attribute it
draws your mark at its full file size.
That is also why the default remains Outlook-unsafe, deliberately: making 180 emit an attribute would fix it for everyone and would change what every existing consumer's mail looks like in one client, which is not a change to make silently.
Why this field exists. A consumer shipped a 480×480 mark — 2× for a 40px logo, the correct decision — and the shell drew it 180px wide on a 520px card. The better the source you supply, the worse the result; a 96px file would have looked fine. This hits the careful consumer, not the careless one.
No height attribute is emitted: this package serves non-square logos, and a
forced square distorts them in exactly the client that honours attributes.
Rendering a logo without logoWidth warns once per process. It changes no
mail — it is there so you find out whether your asset is affected, rather than
us guessing on your behalf. Known so far: one consumer's mark is 480×480 (2× for
a 40px logo, the correct call by their supplier) and was therefore broken in
Outlook. How many others is unmeasured, because only your repo knows your file's
width. If the warning fires and your source is under 180px wide, you were fine;
if it is wider, pass logoWidth.
logoWidth is how you draw the logo. If you also need to produce it at a
sane size, that is @broberg/media-transform — complementary, not an alternative.
fill() escapes — and the order matters
fill(template, vars) // every value HTML-escaped
fillHtml(template, vars) // raw; the unsafe one is the one you have to name
Before 0.6.0, fill() did not escape. vars is dynamic by definition — a
customer's name, a booking reference, something someone typed — so if you were on
an earlier version and passed anything user-supplied through it, assume it
rendered as markup. Measured on 0.5.0:
fill("<p>Hej {name}</p>", { name: '<a href="https://phish.example">Log ind</a>' })
→ <p>Hej <a href="https://phish.example">Log ind</a></p>
⚠️ Render first, then fill.
render THEN fill "Sørensen & Søn" → "Sørensen & Søn" ✓
fill THEN render "Sørensen & Søn" → "Sørensen &amp; Søn" ✗
Render first and {token} is ordinary text that survives escaping untouched, so
each value is escaped exactly once. The wrong order fails in the worst available
direction: perfect for every customer whose name has no &, < or quote — most
of them — reaching production looking correct and breaking on one real person, in
their inbox, where nobody is watching. If you call both, compose them into one
function so a call site cannot get the order wrong. (Filed by cardmem, who hit it
in their own template store.)
A brand colour is a SURFACE and a TEXT colour, and it is rarely both
accentColor used to be printed straight into both jobs — the top bar and the
cta background (surfaces), and the eyebrow and footer link (text) — while the
cta's own label was hardcoded #ffffff. For a dark brand that happens to work.
For a light one it produces a mail nobody can read, and nothing errors: it
renders, it sends, and it looks like a deliberately pale style.
Measured on WebHouse gold #F7BB2E, reported by the cms session after their
Lens contrast critic caught it on a real form notification:
accent as TEXT on white 1.74:1
accent as TEXT on the footer's #f4f4f5 1.58:1
WHITE label on the accent surface 1.74:1 ← was hardcoded
dark label on the accent surface 10.03:1
WCAG AA wants 4.5:1
No fixed label colour can be correct. On #0f7391 white measures 5.41 and
dark 3.22; on gold it is the exact reverse. Only this package sees both sides of
the pair, so from 0.8.0 it picks:
| where the accent is… | what happens |
|---|---|
| the top bar, the cta background, a border — a surface | untouched, exactly your brand |
| the cta label | readableInk() — the shell ink that contrasts more |
| the eyebrow, the footer link — text | readableAccent() — darkened (or lightened) only if below 4.5:1 |
Two exported helpers you can use for your own assertions:
import { contrastRatio, readableInk, readableAccent } from "@broberg/mail-core";
contrastRatio("#ffffff", "#F7BB2E") // 1.74… (null if either is not a hex)
readableInk("#F7BB2E") // "#1a1a1a"
readableAccent("#F7BB2E", "#f4f4f5") // a darker gold that clears AA
readableAccent("#0f7391", "#f4f4f5") // "#0f7391" — already legible, untouched
Nothing moves if your brand was already legible
readableAccent returns its input unchanged above 4.5:1, so a dark accent
renders byte-identically. Proven against the published 0.7.0 across seven
shapes (shell, footer, logo, cta, eyebrow, factBox, noteBox) — not against our
own previous build, which is a different artefact.
SHELL_VERSION still moved to 3, because the output does change for a
light brand. That is what the marker is for: you need to be able to tell "my
template changed" from "the shared shell changed".
Upgrading: raise your shell-version floor in the SAME commit
If your code checks SHELL_VERSION — and it should — move the floor to 3 in
the same commit that adopts 0.8.0. cms found this while rolling back their own
workaround, and it is the sharpest thing in their report:
roll the local patch back, leave the requirement at
>= 2, and a downgrade to 0.7 gives you a white label on gold at 1.74:1 — and nothing fails.
Code that assumes shell 3 while accepting shell 2 is a silent bomb: the workaround that used to compensate is gone, the version that needs compensating is still allowed, and the only symptom is a mail nobody can read. The rollback and the floor are one change, not two.
Two things worth stealing
Do not measure against white. The footer sits on #f4f4f5. #767676 clears
AA against white at 4.54 and fails at 4.13 there — so a helper that used white as
a stand-in would ship an illegible footer link and pass its own test. Measure
against the surface the text actually sits on.
Perceived brightness is not contrast. #0078fa reads as "dark" to the
BT.601 formula, so a brightness-based pick chooses white (4.14) over the dark ink
(4.20). Swept the colour cube in steps of 5: the two disagree on 14,440
colours. This is a region, not an edge case.
SHELL_VERSION — and what it was worth before 0.6.0
SHELL_VERSION is emitted into every mail as <!-- @broberg/mail-core shell vN -->
right after the doctype, so you can tell "my template changed" from "their
shell changed" by reading a stored render.
It read "1" across 0.2.x, 0.3.x and 0.4.x while the rendering changed three
times — the footer rule, the footer text colour, the card background,
signOff's emphasis. A stored "1" therefore proves nothing about which shell
rendered a mail. It is "2" from 0.6.0, and a test now compares the rendered
output against a fixture keyed by the marker: change the output without bumping
the version and the build fails. Two numbers answer two questions — read the
installed package version alongside it.