explanation
Bundul email design system
Written by the build · 2 September 2026 · owner @farhan-s · reviewed 2026-09-01
Bundul email design system
One font, one type scale, one palette for every email Bundul sends — customer or internal.
The tokens below are not a new invention. They were read out of the live Infobip templates
(GET /email/1/templates) and are the style all 50 of them already share. Code now matches
that, instead of each block inventing its own.
Code: src/notifications/email-style.ts
Guard: src/notifications/email-style.spec.ts
1. Why the emails looked stitched together
A Bundul customer email is built from two halves:
| Half | Who owns it | What it covers |
|---|---|---|
| Outer shell | Infobip hosted template | Header image, "Hi {name}", intro, sign-off, footer |
| Inner block | This backend, injected as {$result_block} |
The table of payments, amounts, dates |
The two halves were styled independently and drifted apart:
- The shell renders body copy at 17px in arial, helvetica, sans-serif.
- The injected blocks used 13–14px, a
#6b7280/#111827grey scale, and nofont-familyat all.
So the email changed size, weight and colour partway down — right at the numbers. Four separate palettes existed across the codebase (result blocks, true-up tables, true-up admin, admin alerts), none of them matching the shell.
2. The tokens
Font
arial, helvetica, sans-serif
Used by every one of the 50 Infobip templates. Always declare it explicitly —
<table> breaks font inheritance in Outlook and several other clients, so a block that
relies on the shell's font will render in the client's default instead.
Type scale
| Token | Size | Line height | Use |
|---|---|---|---|
display |
22px | 160% | Headline figure (a true-up total) |
heading |
19px | 160% | Section heading inside the body |
body |
17px | 160% | Default copy — matches "Hi {name}," and the intro |
small |
14px | 160% | Secondary line, e.g. the services under a payment row |
footnote |
10px | 160% | Legal and footer print |
17px is the important one. The shell wraps {$result_block} in a 14px container, so any
block that does not set its own font-size silently renders one step smaller than the copy
directly above it. That was the single most visible part of the problem.
One colour for body content
Everything a customer reads in the body — labels, values, supporting lines, trailing notes —
renders in ink. Hierarchy comes from size and weight only, never colour.
Greying out labels was tried and rejected: it made the block read as a different colour from
the shell's black intro copy, which is the exact drift this system exists to prevent. muted
is reserved for footnote-size print (the shell footer, "sent by" captions). The spec fails the
build if a body block emits the muted grey.
Palette
| Token | Value | Use |
|---|---|---|
ink |
#000000 |
Primary text — matches the shell, which leaves body copy at the client default |
muted |
#5a6478 |
Small print only — footers, legal lines, captions. Never body content |
brand |
#2957ed |
Bundul blue — buttons, links |
navy |
#060f2d |
Emphasis, internal email headers |
onBrand |
#ffffff |
Text on a brand-coloured background |
surface |
#f5f7fc |
The card the content sits on |
surfaceAlt |
#eef2fb |
Table header and total rows |
canvas |
#f7f8f9 |
Page background behind the card |
rule |
#d7deee |
Hairlines and table borders |
positive |
#1a7f4b |
Credit / money back |
negative |
#b3261e |
Debit / amount owed |
Shape and spacing
| Token | Value |
|---|---|
| Button radius | 4px |
| Card / table radius | 8px |
| Body horizontal padding | 30px |
| Customer shell width | 500px |
| Internal shell width | 640–680px (wider — these carry data tables) |
3. Using it
Never hand-write inline styles for email HTML. Compose from the builders:
import { emailDetailTable, emailNote } from 'src/notifications/email-style';
const resultBlock =
emailDetailTable([
{ label: 'Payment 1 — Sep 2', value: '$259.96', strong: true, sub: 'Adobe, Max, T-Mobile' },
{ label: 'Payment 2 — Sep 17', value: '$128.51', strong: true, sub: 'Aqua Finance, Spotify' },
{ label: 'Monthly total', value: '$388.47', total: true },
]) +
emailNote('Same services, same total — now collected in two payments.');
| Builder | What it makes |
|---|---|
emailParagraph(html, { spaceAbove, spaceBelow }) |
Body paragraph. Leave tone alone for customer copy |
emailNote(html) |
14px trailing line under a result block — same colour as body, smaller |
emailHeading(html) |
Section heading |
emailDetailTable(rows) |
Label/value breakdown. Rows support sub, strong, heading, total |
emailDataTable({ columns, rows, totals }) |
Multi-column data grid |
emailCallout(html, tone) |
Warning / danger / info banner |
emailButton(href, label) |
Primary CTA |
emailDivider() |
Hairline rule |
renderEmailShell({ heading, subheading, bodyHtml, footerHtml, width }) |
Full document for emails we render end-to-end |
escapeEmailHtml(value) |
Escape untrusted text before interpolation |
The spec enforces the two rules that actually cause drift: every text node declares
font-family, every text node declares font-size, and no hex code outside the palette
appears in generated HTML.
4. What changed in code
| File | Before | After |
|---|---|---|
payment/orchestration/split-conversion.service.ts |
Hand-rolled 14px table, #6b7280/#111827 |
emailDetailTable + emailNote |
bundle-run-admin/bundle-run-admin.service.ts |
5 separate hand-rolled blocks | Builders |
trueup/emails/trueup-user-analysis.email.ts |
13px grid, #333/#444/#f0f4ff |
emailDataTable |
trueup/emails/trueup-admin.email.ts |
Own shell, Arial, #1a1a2e/#888/#aaa |
renderEmailShell + tokens |
trueup/controllers/trueup-admin.controller.ts |
Own confirmation page style | Tokens |
notifications/notifications.service.ts |
Inter/Arial, slate palette | renderEmailShell + emailDetailTable |
5. Outstanding: drift inside the Infobip templates
The code side is now consistent. Some drift remains in the hosted templates themselves, which can only be fixed in the Infobip console. None of it is severe — the base style is right in all 50 — but it is worth a cleanup pass:
27 of 50 templates pin body copy to helvetica, sans-serif in a <span>, overriding the
base arial, helvetica, sans-serif. On most clients this resolves to the same rendered font,
so it is invisible in practice, but it means a future font change would not apply evenly.
Affected include: Welcome to Bundul, Otp_Request, Verification code, Failed One Sub payment,
Upcoming One Sub Notification v2, Successful Retry Confirmation (all four), Issue Fixed,
Deactivation Scheduled, Post-One Sub Charge Confirmation, both Broadcast_Email templates.
11 of 50 templates use font sizes off the scale (12px, 13px, 20px). Worth normalising to 14px / 17px / 19px:
| Template | Off-scale sizes |
|---|---|
| Addition Outcome | 13 |
| Successful Retry Confirmation ×4 | 12, 13 |
| Customer Retries Failed Services ×2 | 12, 13 |
| Quarterly True up Credit / Credit Confirmation | 12 |
| Verification code | 20 |
| Broadcast_Email | 12, 13 |
Also worth fixing: the footer copyright line renders as Copyright © 2025 — a UTF-8
mojibake in the stored template, present in every one of the 50. And the year still says 2025.
How to read a template
curl -s -H "Authorization: App $INFOBIP_API_KEY" \
"$INFOBIP_BASE_URL/email/1/templates" # list (paged, 20/page)
curl -s -H "Authorization: App $INFOBIP_API_KEY" \
"$INFOBIP_BASE_URL/email/1/templates/205000000060425" # one template's HTML