Config and the renderer
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
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)
}
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
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
}
| Field | Rule | Used for |
|---|---|---|
Domain | Non-empty | {domain}; the sender domain of families without SenderDomain |
SiteURL | An absolute https URL with a host | {site}; the default preferences URL, SiteURL + "/preferences" (so leave off the trailing slash) |
AssetBase | An absolute https URL, or a relative URL with AllowRelativeURLs | The logo URLs |
Theme | Theme.Validate passes | See Themes |
Families | Not nil; Families.Validate(Domain) passes | See Families and streams |
Entities | Every entity has a non-empty Address | {address} |
DefaultRegion | A 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:
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:
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:
AssetBase | Brand.Logo | Logo URL |
|---|---|---|
https://cdn.acme.example/email | logo.png | https://cdn.acme.example/email/logo.png |
https://cdn.acme.example/email/ | logo.png | https://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
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:
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",
})
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
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:
//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
func (t *Template[T]) Name() string
func (t *Template[T]) Meta() TemplateMeta
Meta returns a copy of what the compiler learned:
| Field | Meaning |
|---|---|
Name, Family | The template and family names |
Stream | The family's stream, or its legal stream with legal: true |
Tags | Every merge tag and binding in source order, as TagInfo{Path, Type, Pos, Binding} |
Modules | The module kinds in order, e.g. statement, receipt |
Warnings | Compile 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
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,mailtoortel, 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
stockorproductsmust 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 thesubjectkey. - The recipient address must parse and be a plain
local@domainaddress, andFirstName, 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
frommust 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 urlwith 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 HTML | Result |
|---|---|
| 90 KB (92,160 bytes) or less | Nothing |
| Over 90 KB | A warning in Message.Warnings: name: warning: html is N bytes; gmail clips at 102 KB |
| 100 KB (102,400 bytes) or more | An 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.