Families and streams
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
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,
},
)
| Field | Type | Meaning |
|---|---|---|
Name | string | What templates write in family:. Must be non-empty and unique in the registry. |
From | mail.Address | The fixed sender. Must be zero when FromFrontmatter is set. |
FromFrontmatter | bool | The template's frontmatter from is required and becomes the sender. |
SenderDomain | string | The domain the sender must use. Empty means Config.Domain. May use only {domain}, as in "partners.{domain}". |
Stream | lttr.Stream | The sending stream (see below). |
Header | lttr.HeaderVariant | H1, H2 or H3. |
Footer | lttr.FooterVariant | F1, F2, F3 or F3Internal. |
Copy | lttr.FooterCopy | The footer copy for Footer. |
Masthead | string | The H3 masthead, e.g. "Journal · No. {issue}". Required for H3. |
Banner | string | The H2 banner, e.g. "Internal · Do not forward". Required for H2. |
Legal | *lttr.LegalOverride | What frontmatter legal: true switches to. nil means templates may not set legal. |
Forbidden | []ir.Kind | Modules templates of this family may not use. |
Tracking | bool | Whether 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).
| Stream | Constant | For | What lttr does with it |
|---|---|---|---|
transactional | lttr.Transactional | Mail the recipient triggered: receipts, orders, payouts. | Nothing extra. |
security | lttr.Security | Account security: sign-in codes, password resets. | Nothing extra. |
marketing | lttr.Marketing | Opted-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. |
outreach | lttr.Outreach | Business-to-business mail, kept apart from marketing. | Nothing extra. |
internal | lttr.Internal | Mail 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
| Variant | HTML | Plain text | Requires |
|---|---|---|---|
lttr.H1 | The logo, centred. | The brand name in capitals. | — |
lttr.H2 | A banner row above the logo. | The banner in capitals on the line above the brand. | Family.Banner |
lttr.H3 | A 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.
Write the footer copy
FooterCopy holds every string a footer can show. Which fields appear depends on the footer variant.
| Field | Format | Shown by | Meaning |
|---|---|---|---|
Nav | []NavLink{Label, Href} | F1 | The nav row. Empty means no row. |
Reason | Markdown | F1 | Why the recipient gets this mail. |
Links | Markdown | F1 | The links after the reason, e.g. "[Unsubscribe]({unsubscribe}) · [Preferences]({prefs})". |
Note | Markdown | F2 | The lead line. |
About | plain text | F2 | The value of {about}. Empty drops the service line. |
Service | Markdown | F2 | The service line, e.g. "This is a service email about {about}. [Notification settings]({prefs})". |
OptOut | Markdown | F3 | The opt-out line. |
Internal | Markdown | F3·i | The internal-only line. |
Address | plain text | all | Usually "{brand} · {address}". |
TextLinks | []TextLink{Label, URL} | all | Lines 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:
| Variant | Constant | For | Paragraphs |
|---|---|---|---|
| F1 | lttr.F1 | Marketing | Nav row, then Reason and Links in one paragraph, then Address |
| F2 | lttr.F2 | Service | Note, then Service (when {about} has a value), then Address |
| F3 | lttr.F3 | Outreach | Address, then OptOut |
| F3·i | lttr.F3Internal | Internal | Internal, 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.
| Placeholder | Value |
|---|---|
{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 inMasthead, and{about}cannot be used inAbout.SenderDomainmay 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, orConfig.Domainwhen that is empty). - From the template: set
FromFrontmatter: trueand leaveFromzero. Every template of the family must then setfrom:, and a template of any other family must not.Renderchecks that the evaluatedfromis a plain address on the sender domain, e.g.from: "Marisa Hodge <marisa@partners.acme.example>"forSenderDomain: "partners.{domain}".
Switch to a legal variant
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:
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: <tr.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:
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
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:
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")
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 (
Lookupreturns 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; H3without aMasthead,H2without aBanner;- an unknown kind in
Forbidden; - no sender, a sender that does not parse, a sender outside its domain, or
Fromset together withFromFrontmatter; - copy that does not compile. These errors are
*ir.Errorvalues named after the field, such asfamily "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;
fromis present exactly when the family takes it, and a literalfromis an address;issueis present exactly when the family has an H3 masthead;legal: trueis used only with a family that has a legal variant;- each
footer.*key is one the footer shows (afterlegal), such asfooter.notefor F2 orfooter.reasonfor 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.
| Family | From | Stream | Header | Footer | Forbidden | Tracking |
|---|---|---|---|---|---|---|
buyer | CaribHubs orders@ | transactional | H1 | F2, about "your order" | — | on |
seller | CaribHubs Sellers sellers@ | transactional | H1 | F2, about "your seller account" | — | on |
driver | CaribHubs Drivers drivers@ | transactional | H1 | F2, about "your driver account" | — | on |
security | CaribHubs Security security@ | security | H1 | F2, no about line | image, stories, products, offer, quote | off |
announcement | CaribHubs hello@ | marketing (legal → transactional, F2) | H1 | F1, no nav | — | on |
newsletter | Hub Journal journal@ | marketing | H3, Hub Journal · No. {issue} | F1 with nav | — | on |
promo | CaribHubs hello@ | marketing | H1 | F1 with nav | — | on |
b2b | frontmatter from, on partners.{domain} | outreach | H1 | F3 | image, stories, products, offer, quote | off |
internal | CaribHubs Ops team@ | internal | H2, Internal · Do not forward | F3·i | image, stories, products, offer, quote | off |
Here is the promo family's F1 footer, with its nav row, reason, links and address: