Rendering

Families and streams

Define the kinds of email you send, with their sender, stream, header and footer chrome, footer copy and the modules they may not use.

A family is a kind of email you define: who sends it, which sending stream it belongs to, which header and footer it wears, what the footer says, and which modules its templates may not use. Every template names one in its frontmatter (family: buyer), and lttr applies the family's rules when the template compiles.

lttr ships no families. They are data you build with lttr.Family and collect in a *lttr.Registry. The Quiet example has nine you can copy from.

Define a family

families.go
var Families = lttr.NewRegistry(
    lttr.Family{
        Name:   "buyer",
        From:   mail.Address{Name: "Acme", Address: "orders@acme.example"},
        Stream: lttr.Transactional,
        Header: lttr.H1,
        Footer: lttr.F2,
        Copy: lttr.FooterCopy{
            Note:      "Questions? Reply to this email or visit the [Help Centre]({site}/help).",
            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}"}},
        },
        Tracking: true,
    },
)
FieldTypeMeaning
NamestringWhat templates write in family:. Must be non-empty and unique in the registry.
Frommail.AddressThe fixed sender. Must be zero when FromFrontmatter is set.
FromFrontmatterboolThe template's frontmatter from is required and becomes the sender.
SenderDomainstringThe domain the sender must use. Empty means Config.Domain. May use only {domain}, as in "partners.{domain}".
Streamlttr.StreamThe sending stream (see below).
Headerlttr.HeaderVariantH1, H2 or H3.
Footerlttr.FooterVariantF1, F2, F3 or F3Internal.
Copylttr.FooterCopyThe footer copy for Footer.
MastheadstringThe H3 masthead, e.g. "Journal · No. {issue}". Required for H3.
BannerstringThe H2 banner, e.g. "Internal · Do not forward". Required for H2.
Legal*lttr.LegalOverrideWhat frontmatter legal: true switches to. nil means templates may not set legal.
Forbidden[]ir.KindModules templates of this family may not use.
TrackingboolWhether your sending service may track opens and clicks. Copied to Message.Tracking.

NewRegistry deep-copies every family, so changing your slices or pointers afterwards does not change the registry.

Choose a stream

A stream keeps one kind of mail's sending reputation apart from another's. The Router sends each stream through its own Sender (see Router and streams).

StreamConstantForWhat lttr does with it
transactionallttr.TransactionalMail the recipient triggered: receipts, orders, payouts.Nothing extra.
securitylttr.SecurityAccount security: sign-in codes, password resets.Nothing extra.
marketinglttr.MarketingOpted-in promotional mail.Render sets List-Unsubscribe: <url> and List-Unsubscribe-Post: List-Unsubscribe=One-Click from Recipient.UnsubscribeURL, which must be an https URL.
outreachlttr.OutreachBusiness-to-business mail, kept apart from marketing.Nothing extra.
internallttr.InternalMail to your own staff only.Router.Send refuses it unless Router.InternalAllowed accepts the address.

Because Render sets the one-click headers on every marketing message, a marketing render fails without an https UnsubscribeURL on the recipient, whatever the footer. With an F1 footer the error comes first and reads render: marketing mail needs an unsubscribe url; with any other footer it is render: list-unsubscribe url "" is not https.

Tracking belongs to the family, not the stream. lttr never rewrites links for tracking: Message.Tracking is a flag for your sending service to read.

Pick a header variant

VariantHTMLPlain textRequires
lttr.H1The logo, centred.The brand name in capitals.—
lttr.H2A banner row above the logo.The banner in capitals on the line above the brand.Family.Banner
lttr.H3A masthead eyebrow 14px under the logo.The masthead in capitals on the line below the brand.Family.Masthead, and issue: in every template

The masthead is where {issue} goes: Masthead: "Journal · No. {issue}" and a template with issue: "{{issue}}" render as JOURNAL · NO. 014. A template of an H3 family without issue fails to compile, and so does a template of any other family that sets it.

FooterCopy holds every string a footer can show. Which fields appear depends on the footer variant.

FieldFormatShown byMeaning
Nav[]NavLink{Label, Href}F1The nav row. Empty means no row.
ReasonMarkdownF1Why the recipient gets this mail.
LinksMarkdownF1The links after the reason, e.g. "[Unsubscribe]({unsubscribe}) · [Preferences]({prefs})".
NoteMarkdownF2The lead line.
Aboutplain textF2The value of {about}. Empty drops the service line.
ServiceMarkdownF2The service line, e.g. "This is a service email about {about}. [Notification settings]({prefs})".
OptOutMarkdownF3The opt-out line.
InternalMarkdownF3·iThe internal-only line.
Addressplain textallUsually "{brand} · {address}".
TextLinks[]TextLink{Label, URL}allLines of the plain-text footer, written Label: URL.

Markdown fields are one paragraph. Every string is copy: it may use placeholders but never merge tags, and NewRenderer compiles every field whatever the variant, so a mistake in a field the footer does not show is still reported.

The HTML footer writes its paragraphs in this order:

VariantConstantForParagraphs
F1lttr.F1MarketingNav row, then Reason and Links in one paragraph, then Address
F2lttr.F2ServiceNote, then Service (when {about} has a value), then Address
F3lttr.F3OutreachAddress, then OptOut
F3·ilttr.F3InternalInternalInternal, then Address

The plain-text footer is shorter: the lead (Reason, Note, OptOut or Internal), a blank line, --, the address, then one Label: URL line per TextLinks entry whose URL is not empty. The nav row, Links and the F2 service line have no plain-text form, which is why TextLinks exists. When an F2 footer drops its service line, it drops its TextLinks too, so the text part only carries URLs the variant shows.

A template can replace some of this copy with footer.note, footer.about, footer.reason, footer.nav and footer.optout, but only the keys its footer shows (see Frontmatter).

Use placeholders

Copy uses {name} placeholders from lttr.Vars. The renderer fills them for each message.

PlaceholderValue
{site}Config.SiteURL
{brand}Theme.Brand.Name
{domain}Config.Domain
{region}The recipient's Region, or Config.DefaultRegion
{address}The recipient's EntityAddress, or the address of the region's entity
{about}The F2 about value: footer.about from the template, or Copy.About
{prefs}The recipient's PreferencesURL, or {site}/preferences
{unsubscribe}The recipient's UnsubscribeURL
{issue}The template's evaluated issue:

Placeholders belong to family copy and the footer.* frontmatter keys. Template bodies use merge tags ({{first_name}}) instead; see Merge tags and bindings.

The copy rules are checked when the registry is validated:

  • An unknown placeholder is an error: unknown placeholder "{nope}".
  • A merge tag in copy is an error: merge tags are not allowed in copy.
  • A Markdown field with more than one paragraph is an error: copy must be one paragraph.
  • {issue} is allowed only in Masthead, and {about} cannot be used in About.
  • SenderDomain may use only {domain}.

In URLs (NavLink.Href, TextLink.URL, link targets) a placeholder that opens the URL is inserted as it is, so {site}/help and {prefs} work. A value later in the URL is percent-escaped (D34).

Set the sender

A family either has a fixed sender or takes one from each template:

  • Fixed: set From. Its address must parse and be on the family's sender domain (SenderDomain, or Config.Domain when that is empty).
  • From the template: set FromFrontmatter: true and leave From zero. Every template of the family must then set from:, and a template of any other family must not. Render checks that the evaluated from is a plain address on the sender domain, e.g. from: "Marisa Hodge <marisa@partners.acme.example>" for SenderDomain: "partners.{domain}".

Some mail is marketing most of the time but occasionally carries a legal notice that every user must receive. Legal describes what such a template switches to:

families.go
lttr.Family{
    Name: "announcement", From: mail.Address{Name: "Acme", Address: "hello@acme.example"},
    Stream: lttr.Marketing, Header: lttr.H1, Footer: lttr.F1, Copy: marketingCopy,
    Legal: &lttr.LegalOverride{
        Stream: lttr.Transactional,
        Footer: lttr.F2,
        Copy:   serviceCopy,
    },
}

A template with legal: true then uses the override's stream, footer variant and copy. The header, sender and tracking flag stay the family's. Template.Meta().Stream reports the stream the template will actually use. legal: true in a template whose family has no Legal fails to compile.

Forbid modules

Forbidden lists the modules a family's templates may not use, by their canonical kind:

families.go
Forbidden: []ir.Kind{ir.KindImage, ir.KindStories, ir.KindProducts, ir.KindOffer, ir.KindQuote},

A template that uses one fails to compile with module "image" is not allowed in family "security", positioned at the module. Handoff aliases such as k3 are not kinds: the registry reports unknown module "k3" in Forbidden.

Build the registry

registry.go
func NewRegistry(families ...Family) *Registry
func (r *Registry) Lookup(name string) (Family, bool)
func (r *Registry) Names() []string
func (r *Registry) Validate(domain string) error

NewRegistry never fails. Problems are reported by Validate, which NewRenderer calls with Config.Domain, and which returns every problem at once, one per line. This registry has most of the mistakes you can make:

families.go
reg := lttr.NewRegistry(
    lttr.Family{
        Name: "news", From: mail.Address{Address: "news@example.com"},
        Stream: lttr.Marketing, Header: lttr.H3, Footer: lttr.F1,
        Copy:      lttr.FooterCopy{Reason: "Hi {{name}} and {nope}", Address: "{brand} {issue}"},
        Forbidden: []ir.Kind{"k3"},
    },
    lttr.Family{Name: "news", From: mail.Address{Address: "a@other.com"}, Stream: "bulk", Header: lttr.H2, Footer: lttr.F2},
)
err := reg.Validate("acme.example")
Error
family "news": header H3 requires a masthead
family "news": unknown module "k3" in Forbidden
family "news": sender "news@example.com" is outside its domain "acme.example"
family "news" Copy.Reason:1:4: merge tags are not allowed in copy
family "news" Copy.Reason:1:17: unknown placeholder "{nope}"
family "news" Copy.Address:1:9: {issue} is only allowed in Masthead
family "news": duplicate name

The second news is reported only as a duplicate: its own mistakes (the bulk stream, the missing banner) are not checked until it has a name of its own.

It refuses:

  • an empty or duplicate name (Lookup returns the first of a duplicate, but the registry never validates);
  • an invalid stream, header variant or footer variant, and an invalid stream or footer variant in Legal;
  • H3 without a Masthead, H2 without a Banner;
  • an unknown kind in Forbidden;
  • no sender, a sender that does not parse, a sender outside its domain, or From set together with FromFrontmatter;
  • copy that does not compile. These errors are *ir.Error values named after the field, such as family "promo" Copy.Reason.

Know what Compile checks

lttr.Compile takes the registry because family rules are compile-time (D2). Besides the template itself, it checks that:

  • the family exists: frontmatter: unknown family "x";
  • no module is one the family forbids;
  • from is present exactly when the family takes it, and a literal from is an address;
  • issue is present exactly when the family has an H3 masthead;
  • legal: true is used only with a family that has a legal variant;
  • each footer.* key is one the footer shows (after legal), such as footer.note for F2 or footer.reason for F1.

The registry passed to Compile and the one in Config.Families should be the same. Render looks the family up again by name and fails with render: family "x" is not in this renderer's registry if the renderer does not have it.

Read the Quiet families

The Quiet example defines nine families in examples/quiet/families.go. Its sender domain is caribhubs.com.

FamilyFromStreamHeaderFooterForbiddenTracking
buyerCaribHubs orders@transactionalH1F2, about "your order"—on
sellerCaribHubs Sellers sellers@transactionalH1F2, about "your seller account"—on
driverCaribHubs Drivers drivers@transactionalH1F2, about "your driver account"—on
securityCaribHubs Security security@securityH1F2, no about lineimage, stories, products, offer, quoteoff
announcementCaribHubs hello@marketing (legal → transactional, F2)H1F1, no nav—on
newsletterHub Journal journal@marketingH3, Hub Journal · No. {issue}F1 with nav—on
promoCaribHubs hello@marketingH1F1 with nav—on
b2bfrontmatter from, on partners.{domain}outreachH1F3image, stories, products, offer, quoteoff
internalCaribHubs Ops team@internalH2, Internal · Do not forwardF3·iimage, stories, products, offer, quoteoff

Here is the promo family's F1 footer, with its nav row, reason, links and address:

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