Your own family and theme
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:
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.Nameis non-empty. It appears in the header text, the logo's alt text and the default sign-off ("The Tidewater Books team").LogoandLogoDarkare plain file names underConfig.AssetBase, with no/,..or whitespace.LogoDarkis the white-chip version for dark mode.LogoSizeis 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:
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:
| Variant | For | Fields shown |
|---|---|---|
F1 | Marketing | Nav, Reason, Links |
F2 | Service | Note, and Service with the About value (an empty About drops the service line) |
F3 | Outreach | OptOut |
F3Internal | Internal | Internal |
| Every variant | Address, 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:
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",
}
}
Domainis the sending domain. A family'sFrommust be on it, or on itsSenderDomain.SiteURLmust be an absolute https URL. It is{site}, and the default for{prefs}is{site}/preferenceswhen a recipient has noPreferencesURL.AssetBaseis where you hostlogo.pngandlogo-dark.png.- A recipient's
Regionselects the entity. With no region,DefaultRegionis used, and it must be a key ofEntities. A recipient with anEntityAddressof 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:
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:
---
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:
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:
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.
go test -v .
=== 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
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:
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)
}
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.