Rendering

HTML output

What the HTML part looks like, and how it holds up in Outlook, dark mode and on phones.

The HTML part is built by github.com/sulv-io/lttr/render/html from the laid-out document. It is table-based markup of the kind every mail client renders, including the Word engine inside desktop Outlook. You do not write any of it: templates describe content, and the renderer owns every tag, style and size.

The partials are html/template files embedded in the package and parsed once into an immutable set. Everything that reaches them is decided first: sizes from constants, colours from the validated theme, URLs from CheckURL.

Read the document skeleton

Every email has the same outer structure, in this order:

  1. <!DOCTYPE html>.
  2. <html lang="en"> with the xmlns, xmlns:v and xmlns:o namespaces Outlook needs for VML.
  3. The head: charset, viewport, x-apple-disable-message-reformatting, format-detection (telephone, date, address, email and url all off), color-scheme and supported-color-schemes set to light dark; a <title> holding the subject; the Outlook OfficeDocumentSettings block (AllowPNG, PixelsPerInch 96); and one <style> element.
  4. <body id="body" class="dk-canvas">.
  5. The hidden preheader: the preheader text followed by 60 repeats of &#847;&zwnj;&nbsp;, so the inbox preview does not run on into the email's first words.
  6. An outer full-width table on the canvas colour, an Outlook-only 600px wrapper table, and the inner table capped at max-width:600px.
  7. The header rows, one row per section, and the footer row.
05-campaign.heritage_week.html
<body id="body" class="dk-canvas" style="margin:0;padding:0;width:100%;background-color:#FFFFFF">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#FFFFFF;opacity:0">EC$15 flat delivery, plus 15% off with HERITAGE15.&#847;&zwnj;&nbsp;…</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" class="dk-canvas"><tr><td align="center">
<!--[if mso]><table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="dk-canvas" style="width:100%;max-width:600px;margin:0 auto;background-color:#FFFFFF">
<tr><td align="center" style="padding:36px 0 0 0"><img class="logo-lt" src="https://caribhubs.com/assets/email/logo.png" alt="CaribHubs" width="36" height="36" style="…"><!--[if !mso]><!--><div class="logo-dk" style="display:none;max-height:0;overflow:hidden"><img src="https://caribhubs.com/assets/email/logo-dark.png" alt="CaribHubs" width="36" height="36" style="…"></div><!--<![endif]--></td></tr>
<tr><td class="px" style="padding:40px 56px 36px 56px;text-align:center">…</td></tr>
…

Build with presentation tables

Layout is tables and nothing else:

  • Every <table> carries role="presentation" cellpadding="0" cellspacing="0" border="0", so screen readers do not announce layout tables as data.
  • There are no div-based layouts, and no flex, grid, var( or @import anywhere.
  • Every section is a row: <tr><td class="px" style="padding:{top}px 56px {bottom}px 56px;text-align:…">, with the top and bottom from Layout and spacing. Bands (images and maps) are rows with no padding.
  • Inline styles come from a typed style builder that accepts only token sizes, theme colours and fixed keywords, and reach the templates as template.CSS. Data from your templates never flows into CSS or attribute names.
  • Paragraphs in hero modules (statement, figure, offer) and pull quotes are capped at 440px and centred, and wrapped in a 440px Outlook-only table because Outlook ignores max-width.

Serve Outlook

html/template strips HTML comments, including the conditional comments Outlook depends on. They reach the output only through a few trusted helpers, built from constants and escaped values:

HelperEmitsUsed for
msoOpen N / msoClose<!--[if mso]><table … width="N" align="center" …><tr><td><![endif]--> and its closeThe 600px frame
msoIf / msoEnd<!--[if mso]> … <![endif]-->The OfficeDocumentSettings block, 440px paragraph tables, the VML button
notMsoOpen / notMsoClose<!--[if !mso]><!--> … <!--<![endif]-->Markup Outlook must not see: the dark logo, the HTML button link
vmlPillA v:roundrectThe button as Outlook draws it

The button is written twice. Outlook gets a VML rounded rectangle, 48px tall with arcsize="50%", filled with Light.Action, its label in Light.PillText set in Arial,sans-serif. Its width is estimated at 9px per character plus 48px of padding, capped at 488px so a long label cannot widen the email. Every other client gets an <a> inside a bgcolor cell with border-radius:999px, hidden from Outlook by <!--[if !mso]> and mso-hide:all.

Support dark mode

The email declares color-scheme: light dark and carries a dark palette in its head <style>. The light palette is inline; the dark one only takes over in clients that support it:

  • Clients that support prefers-color-scheme, Apple Mail and iOS Mail among them, apply the @media (prefers-color-scheme:dark) block. Elements carry dk-* classes (dk-canvas, dk-ink, dk-body, dk-act, dk-pill, …), and each class maps to a dark palette colour with !important. See Themes for the whole list.
  • Outlook.com rewrites colours itself and marks what it changed with data-ogsc (text) and data-ogsb (backgrounds). The same rules are repeated as [data-ogsc] .dk-ink{…} for text and border colours and [data-ogsb] .dk-canvas{…} for backgrounds, so its result uses your dark palette rather than its own guesses.
  • The logo swaps: .logo-lt is hidden and .logo-dk, which is collapsed with display:none;max-height:0;overflow:hidden in light mode, is shown. The dark logo sits inside <!--[if !mso]>, so Outlook never shows both.
  • Clients that read neither keep the light palette, unless they recolour the email themselves.
05-campaign.heritage_week.html
@media (prefers-color-scheme:dark){
.dk-canvas{background-color:#121615!important}
.dk-foot{background-color:#1A1F1D!important}
.dk-ink{color:#F2F2F0!important}
.dk-body{color:#A8ADAB!important}
.dk-act{color:#8BC9C3!important}
…
.logo-lt{display:none!important}
.logo-dk{display:block!important;max-height:none!important;overflow:visible!important}
}
[data-ogsc] .dk-ink{color:#F2F2F0!important}
…
[data-ogsb] .dk-canvas{background-color:#121615!important}
…

Adapt to phones

Below 480px, the rules in @media only screen and (max-width:480px) take over:

ClassOn a phone
.px, .hr-pxSide padding 24px instead of 56px
.h-1, .h-xlHeadings 30/34
.figThe figure amount 46/46
.otpThe code module 34/34, letter spacing 7px
.cta, .cta-cell, .cta-gap, .pill-t, .pill-aAn action row stacks: cells become full-width blocks, the gap is hidden, the button fills the width with 16px/24px padding
.col, .col-2Meta columns stack, the second 20px below the first
.g2, .g2-2, .g2-xStory and product tiles stack with a hairline between them; the filler cell after an odd last tile is hidden
.tile-imgTile images stay 160px tall (D32)
.hero, .hero-tdImage and map bands 240px tall, cropped with object-fit:cover
.step-lProgress labels 11px
.fnav aThe F1 nav links wrap with 10px between rows

Clients that ignore media queries show the 600px layout.

Give every image alt text

  • Logos are <img> with alt set to Theme.Brand.Name, width and height set to Brand.LogoSize, and border:0.
  • Image and map bands are a td with the tone ground as bgcolor and a fixed height, holding <img class="hero" width="600" style="width:100%;height:…;object-fit:cover;display:block;border:0">. Without a source the band is the coloured ground alone, which makes a clean placeholder.
  • A source without alt text is a render error: image: alt is empty for src. The same holds for maps, story and product tiles and receipt thumbnails. Story and product tiles take alt and default it to the title or name (D11).

Check every URL

Every URL in the HTML goes through html.CheckURL before a template sees it: links in prose, buttons and :link actions, logos, images, tiles, thumbnails and the F1 nav.

render/html/urls.go
func CheckURL(raw string, allowRelative bool) (template.URL, error)
InputResult
https://…, http://… with a hostAllowed
mailto:…, tel:… with an addressAllowed
A relative URLAllowed only with Config.AllowRelativeURLs (previews)
Emptyurl "": empty
Whitespace or a control character anywhereurl "…": contains whitespace or control character
Any other schemeurl "javascript:alert(1)": scheme "javascript" not allowed

A failed check is a render error. The renderer never substitutes # for a bad URL. A passing URL is returned unchanged as template.URL, so tel: links are not rewritten by html/template. Merge values inside a URL are escaped before the check (D34, see Config and the renderer).

Stay under 100 KB

Gmail clips HTML over 102 KB. Render warns over 90 KB and fails at 100 KB (see the size limit). The Quiet examples weigh 9 to 16 KB.

Lint the structure

The repository's tests run every rendered example through a structural lint, internal/htmlcheck (an internal package, not importable). It reports:

  • any var(, @import, <script or {{ in the document;
  • flex or grid inside a style attribute or <style> element;
  • a <table> without role="presentation" cellpadding="0" cellspacing="0" border="0";
  • an <img> without alt, width or border:0 in its style;
  • a <div> styled display:flex;
  • a missing <meta name="color-scheme" content="light dark">;
  • a document of 100 KB or more;
  • unbalanced Outlook conditional comments.

Markup inside Outlook-only comments is linted too. See Goldens and testing.

See it in light, dark and on a phone

This is the Quiet campaign email at 600px and 375px. Switch the preview to dark to see the dark palette and the logo swap:

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).
Copyright © 2026