Rendering

Config and the renderer

Build a Renderer from a Config, compile templates against your families, and render them for a recipient.

Rendering takes three pieces: a Config you fill once, a *Renderer that NewRenderer builds from it, and compiled templates. Template.Render combines them with a recipient and the template's data and returns a *Message.

Render a template end to end

main.go
package main

import (
    "fmt"
    "log"
    "net/mail"

    "github.com/sulv-io/lttr"
    "github.com/sulv-io/lttr/examples/quiet"
)

// Welcome is the template's data.
type Welcome struct {
    DashboardURL string `json:"dashboard_url"`
}

var families = lttr.NewRegistry(lttr.Family{
    Name:   "account",
    From:   mail.Address{Name: "Acme", Address: "hello@acme.example"},
    Stream: lttr.Transactional,
    Header: lttr.H1,
    Footer: lttr.F2,
    Copy: lttr.FooterCopy{
        Note:      "Questions? Reply to this email.",
        About:     "your Acme account",
        Service:   "This is a service email about {about}. [Notification settings]({prefs})",
        Address:   "{brand} · {address}",
        TextLinks: []lttr.TextLink{{Label: "Notification settings", URL: "{prefs}"}},
    },
    Tracking: true,
})

var welcome = lttr.MustCompile[Welcome](families, "account.welcome", []byte(`---
template: account.welcome
family: account
subject: "Welcome to Acme, {{first_name}}"
preheader: Your account is ready.
---

::statement
# Welcome aboard.

Your account is ready whenever you are.

:button[Open your dashboard]{href="{{dashboard_url}}"}
::
`))

func main() {
    th := quiet.Theme() // palettes and fonts; use your own theme.Theme
    th.Brand.Name = "Acme"

    r, err := lttr.NewRenderer(lttr.Config{
        Domain:        "acme.example",
        SiteURL:       "https://acme.example",
        AssetBase:     "https://cdn.acme.example/email",
        Theme:         th,
        Families:      families,
        Entities:      map[string]lttr.Entity{"UK": {Address: "1 High Street, London"}},
        DefaultRegion: "UK",
    })
    if err != nil {
        log.Fatal(err)
    }

    to := lttr.Recipient{Address: "ada@example.com", FirstName: "Ada"}
    msg, err := welcome.Render(r, to, Welcome{DashboardURL: "https://acme.example/dashboard"})
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(msg.Subject)
    fmt.Print(msg.Text)
}
Output
Welcome to Acme, Ada
ACME

Welcome aboard.

Your account is ready whenever you are.

Open your dashboard:
https://acme.example/dashboard

Questions? Reply to this email.

--
Acme · 1 High Street, London
Notification settings: https://acme.example/preferences

The rest of this page takes the pieces one at a time.

Fill the config

config.go
type Config struct {
    Domain    string // sending domain, e.g. "example.com"; the {domain} placeholder
    SiteURL   string // absolute https URL; the {site} placeholder
    AssetBase string // base URL of the logo files (absolute https, or relative when AllowRelativeURLs)

    Theme    Theme     // palettes, fonts and brand mark
    Families *Registry // the family registry templates are compiled against

    Entities      map[string]Entity // region → entity, for the footer address
    DefaultRegion string            // region used when a recipient has none; must be in Entities

    AllowRelativeURLs bool // previews only: relative asset and link URLs are allowed
}
FieldRuleUsed for
DomainNon-empty{domain}; the sender domain of families without SenderDomain
SiteURLAn absolute https URL with a host{site}; the default preferences URL, SiteURL + "/preferences" (so leave off the trailing slash)
AssetBaseAn absolute https URL, or a relative URL with AllowRelativeURLsThe logo URLs
ThemeTheme.Validate passesSee Themes
FamiliesNot nil; Families.Validate(Domain) passesSee Families and streams
EntitiesEvery entity has a non-empty Address{address}
DefaultRegionA key of Entities{region} and {address} when the recipient has no region
AllowRelativeURLs—Previews only

There is no DefaultConfig: every value is yours (D15). examples/quiet.Config(assetBase) returns the Quiet one.

Resolve the entity address

The footer's {address} is the legal entity the mail is sent on behalf of. You list one per region:

config.go
Entities: map[string]lttr.Entity{
    "St Kitts & Nevis":  {Address: "Basseterre, St Kitts"},
    "Antigua & Barbuda": {Address: "St John's, Antigua"},
},
DefaultRegion: "St Kitts & Nevis",

For each message, Render takes the region from Recipient.Region, or DefaultRegion when that is empty; that is {region}. The address is Recipient.EntityAddress when set, otherwise the address of that region's entity. A recipient whose region is not in Entities and who has no EntityAddress fails with render: no entity address for region "FR".

The recipient carries the rest of the per-person values:

data/types.go
type Recipient struct {
    Address        string // RFC 5322 address
    FirstName      string `json:"first_name"`
    Region         string `json:"region"`
    EntityAddress  string `json:"entity_address"`
    PreferencesURL string `json:"preferences_url"`
    UnsubscribeURL string `json:"unsubscribe_url"`
}

PreferencesURL falls back to SiteURL + "/preferences". UnsubscribeURL has no fallback: marketing mail needs it (see streams). Templates can use these fields as merge tags, such as {{first_name}}.

Serve the logos

AssetBase is where your logo files live. The header's logo URLs are AssetBase with a single / and Theme.Brand.Logo or Theme.Brand.LogoDark appended:

AssetBaseBrand.LogoLogo URL
https://cdn.acme.example/emaillogo.pnghttps://cdn.acme.example/email/logo.png
https://cdn.acme.example/email/logo.pnghttps://cdn.acme.example/email/logo.png
/gallery/assets (preview only)logo.png/gallery/assets/logo.png

Host the files where mail clients can fetch them over https. lttr links to them; it does not attach them to the message.

Allow relative URLs in previews

AllowRelativeURLs: true lets AssetBase, and every link and image in a template, be relative. It exists for local previews, where the logos are served next to the HTML (lttr preview -assets, lttr serve). Never set it for mail you send: a relative URL means nothing in an inbox. Without it, NewRenderer refuses a relative AssetBase and Render refuses a relative link with relative url not allowed.

Build the renderer

renderer.go
func NewRenderer(cfg Config) (*Renderer, error)

NewRenderer validates the whole config and compiles every family's copy once. It reports every problem together, one per line. For this config:

config.go
th := quiet.Theme()
th.Light.Ink = "red"
_, err := lttr.NewRenderer(lttr.Config{
    Domain: "acme.example", SiteURL: "http://acme.example", AssetBase: "assets",
    Theme: th, Families: news, // one H3 family: no Masthead, Reason "You subscribed. {nope}"
    Entities:      map[string]lttr.Entity{"UK": {Address: "1 High Street, London"}},
    DefaultRegion: "GB",
})
Error
config: SiteURL "http://acme.example" must be an absolute https URL
config: AssetBase "assets" must be an absolute https URL
theme: Light.Ink: invalid colour "red" (want #RRGGBB)
config: DefaultRegion "GB" is not in Entities
family "news": header H3 requires a masthead
family "news" Copy.Reason:1:17: unknown placeholder "{nope}"

It refuses an empty Domain; a SiteURL that is not an absolute https URL; an empty or invalid AssetBase; an invalid theme; a nil Families or one that does not validate; a DefaultRegion that is not in Entities; and an entity with an empty address.

A *Renderer is immutable and safe for concurrent use. It keeps its own copy of Entities. Build one at start-up and share it.

Compile templates

template.go
func Compile[T any](reg *Registry, name string, src []byte) (*Template[T], error)
func MustCompile[T any](reg *Registry, name string, src []byte) *Template[T]

Compile checks the template against its data type T (which must be a struct) and the families in reg: the Comark source, the frontmatter, every module and prop, every merge tag and binding, and the family rules. name must equal the frontmatter's template:. On failure it returns every error joined; each is an *ir.Error carrying name:line:col. See Compile errors.

MustCompile panics instead. Use it for package-level templates, so a broken template stops the program at start-up rather than at the first send:

templates.go
//go:embed templates/welcome.md
var welcomeSrc []byte

var Welcome = lttr.MustCompile[WelcomeData](Families, "account.welcome", welcomeSrc)

A *Template[T] is immutable and safe for concurrent use.

Inspect a template

template.go
func (t *Template[T]) Name() string
func (t *Template[T]) Meta() TemplateMeta

Meta returns a copy of what the compiler learned:

FieldMeaning
Name, FamilyThe template and family names
StreamThe family's stream, or its legal stream with legal: true
TagsEvery merge tag and binding in source order, as TagInfo{Path, Type, Pos, Binding}
ModulesThe module kinds in order, e.g. statement, receipt
WarningsCompile warnings, formatted name:line:col: warning: msg

For the welcome template above, Meta().Tags is first_name (string, 4:28) and dashboard_url (string, 13:36). For the Quiet examples, lttr tags -t order.out_for_delivery prints the same list from the command line.

Render a message

render.go
func (t *Template[T]) Render(r *Renderer, to Recipient, v T) (*Message, error)

Render evaluates the template with v and to, lays it out, renders the HTML and plain-text parts, and returns a *Message holding the envelope, subject, preheader, both bodies, the stream, the tracking flag and any warnings. On a marketing stream it also sets the one-click List-Unsubscribe headers. On error the message is nil. It is safe for concurrent use.

Everything that depends on data is checked here, and any failure is an error rather than a broken email:

  • The family must be in the renderer's registry: render: family "x" is not in this renderer's registry.
  • Merge tags without a fallback need a value (D8: a nil pointer on the path, a missing map key or an empty formatted value count as none): account.welcome:13:36: no value for {{dashboard_url}}.
  • URLs must pass CheckURL: https, http, mailto or tel, no whitespace or control characters, a host for http(s), an address for mailto and tel, never empty. This covers links, buttons, logos, images, tiles, thumbnails, footer nav and the text-only footer URLs (D31, D34): account.welcome:13:1: url "javascript:alert(1)": scheme "javascript" not allowed.
  • URL merge values are escaped. Only a merge tag or placeholder that opens a URL is inserted as it is; any later value is percent-escaped, so a value cannot add a query parameter or move the host (D34). Pass whole URLs as data (href="{{track_url}}") rather than assembling them from parts.
  • Images with a source need alt text: image: alt is empty for src, likewise for maps, stories, products and receipt thumbnails.
  • Lists bound to stock or products must not be empty, and every action, story and product link must evaluate to a URL (D31).
  • The subject must not be empty or contain CR, LF or NUL: subject is empty, reported at the subject key.
  • The recipient address must parse and be a plain local@domain address, and FirstName, which becomes the display name, must not contain CR, LF or NUL (D34).
  • The region must have an entity address: render: no entity address for region "FR".
  • A frontmatter from must be a plain address on the family's sender domain: from "x@elsewhere.com" is not on partners.acme.example.
  • Marketing mail needs an https Recipient.UnsubscribeURL: render: marketing mail needs an unsubscribe url with an F1 footer.
  • The HTML must stay under 100 KB.

Errors that come from the template carry name:line:col, so they read like compile errors.

Stay under the size limit

Gmail clips a message whose HTML is over 102 KB and hides the rest behind a link. Render enforces a margin below that:

Rendered HTMLResult
90 KB (92,160 bytes) or lessNothing
Over 90 KBA warning in Message.Warnings: name: warning: html is N bytes; gmail clips at 102 KB
100 KB (102,400 bytes) or moreAn error: render: html is N bytes; gmail clips at 102 KB

The Quiet examples render between 9 and 16 KB. A long bound list (products, stock, a big table) is the usual way to grow past the limit.

Copyright © 2026