@broberg/mail-core

中文文档
0.10.1发布于 2026/10/28MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 2423

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

原始使用文档
文档目录19 节
  1. Code and magic-link mail: codeMail() (since 0.10.0)
  2. Why accentColor is required, not defaulted
  3. Dark cards work
  4. Email-client compatibility
  5. Outlook iOS in dark mode — measured, and the direction is i…
  6. Fonts: fontSerif and fontSans are fallback STACKS
  7. Logo attachments
  8. Signatures — three tiers, one axis each
  9. The legacy form still works, byte for byte
  10. The centred 180px logo is a deliberate shared choice
  11. Brand values are validated, and an invalid one throws
  12. Loading a webfont: webfontHref (since 0.9.0)
  13. Sizing the logo — set logoWidth , and set it even when 180 …
  14. fill() escapes — and the order matters
  15. A brand colour is a SURFACE and a TEXT colour, and it is ra…
  16. Nothing moves if your brand was already legible
  17. Upgrading: raise your shell-version floor in the SAME commit
  18. Two things worth stealing
  19. SHELL_VERSION — and what it was worth before 0.6.0

@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"],
});

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 2 is 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 on BrandColors.textColor, so your editor shows it where you set the value.
  • Three force-light techniques had ZERO effect there, not a partial one: light only color-scheme metas + CSS, [data-ogsc]/[data-ogsb] rules, and #fffffe instead 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:

  • name is bold, not bold-and-darker. On a real palette #1a1c2b is 16.86:1 on white and #0b0e15 is 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.
  • meta has 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 #8486a6 at 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/css or /css2 on 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_VERSION stays 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 &amp; Søn"      ✓
fill THEN render   "Sørensen & Søn"  →  "Sørensen &amp;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 surfaceuntouched, exactly your brand
the cta labelreadableInk() — the shell ink that contrasts more
the eyebrow, the footer link — textreadableAccent() — 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.

版本与发布记录

@broberg/mail-core 共发布 15 个版本,首次发布于 2026/07/03;过去 12 个月发布了 15 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.10.1latest2026/10/2811 天前
0.10.02026/10/2613 天前
0.9.22026/10/2316 天前
0.9.12026/10/2217 天前
0.9.02026/09/291 个月前
0.8.12026/09/062 个月前

关于 @broberg/mail-core 的常见问题

@broberg/mail-core 是什么?

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

如何安装 @broberg/mail-core?

在项目目录运行 npm install @broberg/mail-core,或使用 pnpm add / yarn add / bun add。

@broberg/mail-core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@broberg/mail-core 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@broberg/mail-core 可以商用吗?

@broberg/mail-core 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@broberg/mail-core 的下载量有多少?

根据 npm 官方下载统计,@broberg/mail-core 在 2026/10/09 – 2026/11/07 这 30 天内被下载 2,423 次,日均约 81 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@broberg/mail-core 还在维护吗?

最新版本 0.10.1 发布于 2026/10/28(11 天前);至今共发布 15 个版本,其中过去 12 个月发布了 15 个;首次发布于 2026/07/03。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。