Guides

Your own family and theme

Set lttr up for your own brand, step by step, from a theme and a family registry to a first template and a test that renders it.

lttr holds no brand. Your service supplies the colours, fonts and logo (a Theme), the kinds of email it sends (a Registry of families), and the legal addresses its mail is sent from (entities in the Config). This guide builds all three for a fictional bookshop, Tidewater Books, then writes one template and a test for it.

Everything below goes into one main.go so you can run it as it stands; the complete program is at the end. In a real service, split it the way examples/quiet does: theme.go, families.go, config.go, templates/*.md, templates.go and samples.go. See The Quiet example.

Define the theme

A lttr.Theme has four parts. Light and Dark are the two palettes, Fonts holds three CSS font stacks, and Brand is the sender's mark:

main.go
var brand = lttr.Theme{
    Light: theme.Light{
        Ink: "#1B1F2A", Body: "#4A5060", Secondary: "#6A7080", StrongBody: "#343A48",
        Action: "#1F3A68", PillText: "#FFFFFF", Accent: "#A8323A",
        Hairline: "#E2E4EA", RowRule: "#ECEEF2", NoticeBorder: "#D5D8E0",
        Ground: "#F5F6F8", Canvas: "#FFFFFF",
        WarnBg: "#FEF5E0", WarnBorder: "#F0AE23", WarnText: "#5A3D04",
        OutOfStock: "#9D1411", BannerBg: "#F8ECEC", BannerText: "#7A2229",
        Warm: "#F4ECE4", Sea: "#E4ECF4", Map: "#E6EEF4", Sun: "#1F3A68",
    },
    Dark: theme.Dark{
        Canvas: "#12151C", Footer: "#1A1E27", Hairline: "#2A2F3A",
        Headline: "#F1F2F5", Body: "#A9AEBA",
        Action: "#9DB8E6", PillText: "#0E1A30",
        CurrentStep: "#E8878D", AccentText: "#F2B3B7",
        BannerBg: "#2A1618", BannerText: "#F2B3B7", Placeholder: "#1B1F28",
        WarnBg: "#2A2210", WarnBorder: "#6B5314", WarnText: "#F0D9A8",
        OutOfStock: "#F08A85",
    },
    Fonts: theme.Fonts{
        Serif: "Georgia,'Times New Roman',serif",
        Sans:  "Arial,Helvetica,sans-serif",
        Mono:  "'Courier New',Courier,monospace",
    },
    Brand: theme.Brand{Name: "Tidewater Books", Logo: "logo.png", LogoDark: "logo-dark.png", LogoSize: 36},
}

Action colours buttons and links, and Accent colours editorial eyebrows and the current progress step. Warm, Sea, Map and Sun are the placeholder grounds shown when a tile or band has no image. Themes lists where every token is used.

Every field is required, and Theme.Validate checks each one before any of it reaches CSS or markup:

  • every colour is exactly #RRGGBB
  • each font stack is non-empty and uses only letters, digits, spaces, commas, ' and -
  • Brand.Name is non-empty. It appears in the header text, the logo's alt text and the default sign-off ("The Tidewater Books team").
  • Logo and LogoDark are plain file names under Config.AssetBase, with no /, .. or whitespace. LogoDark is the white-chip version for dark mode.
  • LogoSize is the display size in CSS pixels, from 16 to 96

You don't have to call Validate yourself, because NewRenderer does. It reports every problem, one line each:

theme: Light.Action: invalid colour "#12345" (want #RRGGBB)

Write the families

A family is one kind of email you send. It fixes the sender, the sending stream, the header and footer chrome, the footer copy and the modules its templates may not use. Tidewater sends order mail and a newsletter:

main.go
var families = lttr.NewRegistry(
    lttr.Family{
        Name:   "orders",
        From:   mail.Address{Name: "Tidewater Books", Address: "orders@tidewater.example"},
        Stream: lttr.Transactional,
        Header: lttr.H1,
        Footer: lttr.F2,
        Copy: lttr.FooterCopy{
            Note:      "Questions about your order? Reply to this email.",
            About:     "your order",
            Service:   "This is a service email about {about}. [Notification settings]({prefs})",
            Address:   "{brand} · {address}",
            TextLinks: []lttr.TextLink{{Label: "Notification settings", URL: "{prefs}"}},
        },
        Forbidden: []ir.Kind{ir.KindProducts, ir.KindOffer},
    },
    lttr.Family{
        Name:     "notes",
        From:     mail.Address{Name: "Tidewater Notes", Address: "notes@tidewater.example"},
        Stream:   lttr.Marketing,
        Header:   lttr.H3,
        Masthead: "Tidewater Notes · No. {issue}",
        Footer:   lttr.F1,
        Copy: lttr.FooterCopy{
            Nav:       []lttr.NavLink{{Label: "New books", Href: "{site}/new"}, {Label: "Events", Href: "{site}/events"}},
            Reason:    "You're getting Tidewater Notes because you signed up at {site}.",
            Links:     "[Unsubscribe]({unsubscribe}) · [Preferences]({prefs})",
            Address:   "{brand} · {address}",
            TextLinks: []lttr.TextLink{{Label: "Unsubscribe", URL: "{unsubscribe}"}, {Label: "Preferences", URL: "{prefs}"}},
        },
        Tracking: true,
    },
)

orders uses the service footer (F2), and its templates may not use the promotional products and offer modules. notes uses the H3 newsletter masthead, which requires Masthead copy and makes issue: a required frontmatter key in its templates. It also uses the marketing footer (F1). A marketing email must be rendered for a recipient with an UnsubscribeURL, and Render adds the one-click List-Unsubscribe headers for it.

Which FooterCopy fields a footer shows depends on its variant:

VariantForFields shown
F1MarketingNav, Reason, Links
F2ServiceNote, and Service with the About value (an empty About drops the service line)
F3OutreachOptOut
F3InternalInternalInternal
Every variantAddress, then the plain-text TextLinks lines

Copy is one paragraph of Markdown, or plain text, with {name} placeholders filled per message: {site}, {brand}, {domain}, {region}, {address}, {about}, {prefs}, {unsubscribe} and {issue}. Merge tags ({{…}}) aren't allowed in family copy. A few more options aren't used here: FromFrontmatter with a SenderDomain for senders chosen per template, Banner for the H2 internal header, and Legal for a template that must switch stream and footer with legal: true. See Families and streams.

NewRegistry never fails. The registry is checked by Registry.Validate(domain), which NewRenderer calls. A sender outside your domain and a mistyped placeholder, for example, come back together:

family "orders": sender "orders@elsewhere.example" is outside its domain "tidewater.example"
family "orders" Copy.Note:1:12: unknown placeholder "{sitee}"

Add entities and the config

The Config puts it together. Entities maps a region to the legal entity the mail is sent on behalf of, and its address fills {address} in the footer:

main.go
func config() lttr.Config {
    return lttr.Config{
        Domain:        "tidewater.example",
        SiteURL:       "https://tidewater.example",
        AssetBase:     "https://cdn.tidewater.example/email",
        Theme:         brand,
        Families:      families,
        Entities:      map[string]lttr.Entity{"UK": {Address: "4 Quay Street, Falmouth"}},
        DefaultRegion: "UK",
    }
}
  • Domain is the sending domain. A family's From must be on it, or on its SenderDomain.
  • SiteURL must be an absolute https URL. It is {site}, and the default for {prefs} is {site}/preferences when a recipient has no PreferencesURL.
  • AssetBase is where you host logo.png and logo-dark.png.
  • A recipient's Region selects the entity. With no region, DefaultRegion is used, and it must be a key of Entities. A recipient with an EntityAddress of its own uses that address instead.

NewRenderer(config()) validates all of it at once and returns a *Renderer that is immutable and safe to share. See Config and the renderer.

Write the first template

A template needs a Go data type. Every merge tag and binding is checked against that type when the template compiles, so a misspelt field fails at startup, not at send time:

main.go
type OrderDispatched struct {
    OrderID   string     `json:"order_id"`
    BookCount int        `json:"book_count"`
    Arrives   string     `json:"arrives"`
    Subtotal  data.Money `json:"subtotal"`
    Delivery  data.Money `json:"delivery"`
    TrackURL  string     `json:"track_url"`
}

Then the template. Its template: key must equal the name you compile it under, and family: must name a family in the registry:

templates/order-dispatched.md
---
template: order.dispatched
family: orders
subject: "Order {{order_id}} is on its way"
preheader: "{{book_count}} books, arriving {{arrives}}."
---

::statement{eyebrow="Order {{order_id}}"}
# Your books are on their way.

Hi {{first_name | there}}, we've packed your order and handed it to the courier. It should arrive {{arrives}}.

:button[Track parcel]{href="{{track_url}}"}
::

::breakdown
| Books · {{book_count}} | {{subtotal}} |
| --- | ---: |
| Delivery | {{delivery}} |
::

::signoff
::

{{first_name}} isn't a field of OrderDispatched. A path that isn't in your data type is looked up on the Recipient, so it is the recipient's first name, with "there" as the fallback when they have none. The signoff with no attributes closes with "Thanks," and "The Tidewater Books team".

Compile it once, into a package-level variable. MustCompile panics with every error and its name:line:col, so a broken template stops the program at startup:

main.go
var orderDispatched = lttr.MustCompile[OrderDispatched](families, "order.dispatched", []byte(orderDispatchedSrc))

The complete program keeps the template in a string constant, orderDispatchedSrc, so it fits in one file. In a service, keep the .md files in a directory and read them with //go:embed templates/*.md, as examples/quiet/templates.go does.

Test it

Compiling at init already fails go test on a broken template. The checks that depend on data run only when you render: merge tags with no value and no fallback, URLs (the footer's text-only links included), the entity address and the unsubscribe rule. Add a test that renders every template with sample data. Put main_test.go next to main.go, in the same package:

main_test.go
import (
    "errors"
    "strings"
    "testing"
    "time"

    "github.com/sulv-io/lttr"
    "github.com/sulv-io/lttr/data"
    "github.com/sulv-io/lttr/ir"
)

func sampleOrder() OrderDispatched {
    return OrderDispatched{
        OrderID:   "TW-3107",
        BookCount: 3,
        Arrives:   "on Thursday",
        Subtotal:  data.Money{Minor: 4297, Currency: data.GBPCode},
        Delivery:  data.Money{Minor: 295, Currency: data.GBPCode},
        TrackURL:  "https://tidewater.example/orders/TW-3107",
    }
}

func TestOrderDispatchedRenders(t *testing.T) {
    r, err := lttr.NewRenderer(config())
    if err != nil {
        t.Fatal(err)
    }
    // No first name: the {{first_name | there}} fallback must carry it.
    m, err := orderDispatched.Render(r, lttr.Recipient{Address: "ana@example.com"}, sampleOrder())
    if err != nil {
        t.Fatal(err)
    }
    if len(m.Warnings) > 0 {
        t.Errorf("warnings: %q", m.Warnings)
    }
    if !strings.Contains(m.Text, "Hi there,") {
        t.Errorf("fallback not used:\n%s", m.Text)
    }
    if _, err := m.MIME(time.Now()); err != nil {
        t.Fatal(err)
    }
}

func TestOrdersRefuseOffers(t *testing.T) {
    src := "---\ntemplate: order.promo\nfamily: orders\nsubject: Ten percent off\n---\n\n" +
        "::offer\n## 10% off with `TIDE10`\n\nUntil Sunday.\n::\n"
    _, err := lttr.Compile[struct{}](families, "order.promo", []byte(src))
    var e *ir.Error
    if !errors.As(err, &e) {
        t.Fatalf("want an *ir.Error, got %v", err)
    }
    t.Log(err)
}

The second test proves the family rule works: an offer in an orders template is a compile error at the module's position.

Terminal
go test -v .
Output
=== RUN   TestOrderDispatchedRenders
--- PASS: TestOrderDispatchedRenders (0.00s)
=== RUN   TestOrdersRefuseOffers
    main_test.go:54: order.promo:7:1: module "offer" is not allowed in family "orders"
--- PASS: TestOrdersRefuseOffers (0.00s)
PASS
The lttr CLI (lint, tags, preview, serve) is wired to examples/quiet. It finds a template's data type by looking its template: name up among the nine examples, so it can't check your templates. For your own templates, a render test like the one above does that job. To see your emails while you work, write each message's HTML to a file and open it in a browser.

Run the complete program

The pieces above in one file. It renders the order email for Ana and prints the subject and the plain-text part:

main.go
package main

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

    "github.com/sulv-io/lttr"
    "github.com/sulv-io/lttr/data"
    "github.com/sulv-io/lttr/ir"
    "github.com/sulv-io/lttr/theme"
)

// brand is the Theme: two palettes, three font stacks and the logo.
var brand = lttr.Theme{
    Light: theme.Light{
        Ink: "#1B1F2A", Body: "#4A5060", Secondary: "#6A7080", StrongBody: "#343A48",
        Action: "#1F3A68", PillText: "#FFFFFF", Accent: "#A8323A",
        Hairline: "#E2E4EA", RowRule: "#ECEEF2", NoticeBorder: "#D5D8E0",
        Ground: "#F5F6F8", Canvas: "#FFFFFF",
        WarnBg: "#FEF5E0", WarnBorder: "#F0AE23", WarnText: "#5A3D04",
        OutOfStock: "#9D1411", BannerBg: "#F8ECEC", BannerText: "#7A2229",
        Warm: "#F4ECE4", Sea: "#E4ECF4", Map: "#E6EEF4", Sun: "#1F3A68",
    },
    Dark: theme.Dark{
        Canvas: "#12151C", Footer: "#1A1E27", Hairline: "#2A2F3A",
        Headline: "#F1F2F5", Body: "#A9AEBA",
        Action: "#9DB8E6", PillText: "#0E1A30",
        CurrentStep: "#E8878D", AccentText: "#F2B3B7",
        BannerBg: "#2A1618", BannerText: "#F2B3B7", Placeholder: "#1B1F28",
        WarnBg: "#2A2210", WarnBorder: "#6B5314", WarnText: "#F0D9A8",
        OutOfStock: "#F08A85",
    },
    Fonts: theme.Fonts{
        Serif: "Georgia,'Times New Roman',serif",
        Sans:  "Arial,Helvetica,sans-serif",
        Mono:  "'Courier New',Courier,monospace",
    },
    Brand: theme.Brand{Name: "Tidewater Books", Logo: "logo.png", LogoDark: "logo-dark.png", LogoSize: 36},
}

// families is the registry: one family per kind of email you send.
var families = lttr.NewRegistry(
    lttr.Family{
        Name:   "orders",
        From:   mail.Address{Name: "Tidewater Books", Address: "orders@tidewater.example"},
        Stream: lttr.Transactional,
        Header: lttr.H1,
        Footer: lttr.F2,
        Copy: lttr.FooterCopy{
            Note:      "Questions about your order? Reply to this email.",
            About:     "your order",
            Service:   "This is a service email about {about}. [Notification settings]({prefs})",
            Address:   "{brand} · {address}",
            TextLinks: []lttr.TextLink{{Label: "Notification settings", URL: "{prefs}"}},
        },
        Forbidden: []ir.Kind{ir.KindProducts, ir.KindOffer},
    },
    lttr.Family{
        Name:     "notes",
        From:     mail.Address{Name: "Tidewater Notes", Address: "notes@tidewater.example"},
        Stream:   lttr.Marketing,
        Header:   lttr.H3,
        Masthead: "Tidewater Notes · No. {issue}",
        Footer:   lttr.F1,
        Copy: lttr.FooterCopy{
            Nav:       []lttr.NavLink{{Label: "New books", Href: "{site}/new"}, {Label: "Events", Href: "{site}/events"}},
            Reason:    "You're getting Tidewater Notes because you signed up at {site}.",
            Links:     "[Unsubscribe]({unsubscribe}) · [Preferences]({prefs})",
            Address:   "{brand} · {address}",
            TextLinks: []lttr.TextLink{{Label: "Unsubscribe", URL: "{unsubscribe}"}, {Label: "Preferences", URL: "{prefs}"}},
        },
        Tracking: true,
    },
)

// config is everything the Renderer needs: domain, site, assets, theme,
// families and the entity address each region's mail is sent from.
func config() lttr.Config {
    return lttr.Config{
        Domain:        "tidewater.example",
        SiteURL:       "https://tidewater.example",
        AssetBase:     "https://cdn.tidewater.example/email",
        Theme:         brand,
        Families:      families,
        Entities:      map[string]lttr.Entity{"UK": {Address: "4 Quay Street, Falmouth"}},
        DefaultRegion: "UK",
    }
}

// OrderDispatched is the data of the order.dispatched template.
type OrderDispatched struct {
    OrderID   string     `json:"order_id"`
    BookCount int        `json:"book_count"`
    Arrives   string     `json:"arrives"`
    Subtotal  data.Money `json:"subtotal"`
    Delivery  data.Money `json:"delivery"`
    TrackURL  string     `json:"track_url"`
}

const orderDispatchedSrc = `---
template: order.dispatched
family: orders
subject: "Order {{order_id}} is on its way"
preheader: "{{book_count}} books, arriving {{arrives}}."
---

::statement{eyebrow="Order {{order_id}}"}
# Your books are on their way.

Hi {{first_name | there}}, we've packed your order and handed it to the courier. It should arrive {{arrives}}.

:button[Track parcel]{href="{{track_url}}"}
::

::breakdown
| Books · {{book_count}} | {{subtotal}} |
| --- | ---: |
| Delivery | {{delivery}} |
::

::signoff
::
`

var orderDispatched = lttr.MustCompile[OrderDispatched](families, "order.dispatched", []byte(orderDispatchedSrc))

func main() {
    r, err := lttr.NewRenderer(config())
    if err != nil {
        log.Fatal(err)
    }
    m, err := orderDispatched.Render(r,
        lttr.Recipient{Address: "ana@example.com", FirstName: "Ana"},
        OrderDispatched{
            OrderID:   "TW-3107",
            BookCount: 3,
            Arrives:   "on Thursday",
            Subtotal:  data.Money{Minor: 4297, Currency: data.GBPCode},
            Delivery:  data.Money{Minor: 295, Currency: data.GBPCode},
            TrackURL:  "https://tidewater.example/orders/TW-3107",
        })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(m.Subject)
    fmt.Print(m.Text)
}
Output
Order TW-3107 is on its way
TIDEWATER BOOKS

ORDER TW-3107

Your books are on their way.

Hi Ana, we've packed your order and handed it to the courier. It
should arrive on Thursday.

Track parcel:
https://tidewater.example/orders/TW-3107

Books · 3 ....................... £42.97
Delivery ........................ £2.95

Thanks,
The Tidewater Books team

Questions about your order? Reply to this email.

--
Tidewater Books · 4 Quay Street, Falmouth
Notification settings: https://tidewater.example/preferences

The footer came from the orders family: the note, then {brand} · {address} with the UK entity's address, then the TextLinks line with {prefs} defaulted to {site}/preferences. The HTML part is in m.HTML. To send it, give a Router one Sender per stream. See Router and streams and SMTP.

Copyright © 2026