Bundul
Internal
Browse docs
Waiting for review

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 / #111827 grey scale, and no font-family at 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