Quick start
This page builds one transactional email end to end: an "order shipped" message with a hero, a total and a sign-off. Everything fits in one file, which you can copy, run, and then take apart section by section.
Run the program
Create a module and add lttr (see Installation for private module access):
mkdir quickstart && cd quickstart
go mod init example.com/quickstart
go get github.com/sulv-io/lttr
Save this as main.go:
package main
import (
"context"
"fmt"
"log"
"net/mail"
"github.com/sulv-io/lttr"
"github.com/sulv-io/lttr/data"
"github.com/sulv-io/lttr/theme"
)
// OrderShipped is the template's data. Every merge tag and binding in the
// template is checked against this type when the template compiles.
type OrderShipped struct {
OrderID string `json:"order_id"`
ETA string `json:"eta"`
Total data.Money `json:"total"`
TrackURL string `json:"track_url"`
}
// The template: Markdown plus Comark components. In a real service it
// usually lives in a .md file pulled in with //go:embed.
const orderShippedSrc = `---
template: order.shipped
family: orders
subject: "Order {{order_id}} is on its way"
preheader: "Arriving by {{eta}}."
---
::statement{eyebrow="Order {{order_id}}"}
# On its way.
Hi {{first_name | there}}, your order will reach you by {{eta}}.
:button[Track delivery]{href="{{track_url}}"}
::
::breakdown
| Order total | {{total}} |
| --- | ---: |
| Delivery | Free |
::
::signoff
::
`
// brand is the Theme: two palettes, three font stacks and the logo.
var brand = lttr.Theme{
Light: theme.Light{
Ink: "#141410", Body: "#52524A", Secondary: "#6B6B61", StrongBody: "#3C3C35",
Action: "#0B4740", PillText: "#FFFFFF", Accent: "#C84A0E",
Hairline: "#E4E4E0", RowRule: "#EDEDEA", NoticeBorder: "#D8D8D3",
Ground: "#F7F7F5", Canvas: "#FFFFFF",
WarnBg: "#FEF5E0", WarnBorder: "#F0AE23", WarnText: "#5A3D04",
OutOfStock: "#9D1411", BannerBg: "#FDF0E8", BannerText: "#822E09",
Warm: "#F6EDE6", Sea: "#E3F0EE", Map: "#E8F4F3", Sun: "#062320",
},
Dark: theme.Dark{
Canvas: "#121615", Footer: "#1A1F1D", Hairline: "#2A302E",
Headline: "#F2F2F0", Body: "#A8ADAB",
Action: "#8BC9C3", PillText: "#062320",
CurrentStep: "#EC8A50", AccentText: "#F4B58C",
BannerBg: "#2A1A10", BannerText: "#F4B58C", Placeholder: "#1B201E",
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: "Acme", Logo: "logo.png", LogoDark: "logo-dark.png", LogoSize: 36},
}
// families is the consumer's family registry. A family fixes the sender,
// the sending stream, the header and footer chrome and the footer copy.
// Copy uses {name} placeholders that are filled at render time.
var families = lttr.NewRegistry(lttr.Family{
Name: "orders",
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,
})
// Compile once, at init: a template error panics at startup with
// name:line:col, never at send time.
var orderShipped = lttr.MustCompile[OrderShipped](families, "order.shipped", []byte(orderShippedSrc))
func main() {
r, err := lttr.NewRenderer(lttr.Config{
Domain: "acme.example",
SiteURL: "https://acme.example",
AssetBase: "https://cdn.acme.example/email",
Theme: brand,
Families: families,
Entities: map[string]lttr.Entity{"default": {Address: "1 Harbour Street, Springfield"}},
DefaultRegion: "default",
})
if err != nil {
log.Fatal(err) // every config, theme and family problem at once
}
msg, err := orderShipped.Render(r,
lttr.Recipient{Address: "kim@example.com", FirstName: "Kim"},
OrderShipped{
OrderID: "A-1042",
ETA: "5:30 pm",
Total: data.USD(4250), // US$42.50
TrackURL: "https://acme.example/orders/A-1042",
})
if err != nil {
log.Fatal(err)
}
fmt.Println("From: ", msg.From.String())
fmt.Println("To: ", msg.To.String())
fmt.Println("Subject:", msg.Subject)
fmt.Println()
fmt.Println(msg.Text)
// One Sender per stream keeps reputations apart. Point Addr at your
// relay (and set Auth); localhost:1025 is a local catcher such as Mailpit.
router := <tr.Router{Streams: map[lttr.Stream]lttr.Sender{
lttr.Transactional: <tr.SMTP{Addr: "localhost:1025"},
}}
if err := router.Send(context.Background(), msg); err != nil {
log.Fatal(err)
}
fmt.Println("sent to", msg.To.Address)
}
The program sends to an SMTP server on localhost:1025. Start a local catcher such as Mailpit, which listens on that port and shows what arrives at http://localhost:8025, then run it:
mailpit &
go run .
From: "Acme" <orders@acme.example>
To: "Kim" <kim@example.com>
Subject: Order A-1042 is on its way
ACME
ORDER A-1042
On its way.
Hi Kim, your order will reach you by 5:30 pm.
Track delivery:
https://acme.example/orders/A-1042
Order total ..................... US$42.50
Delivery ........................ Free
Thanks,
The Acme team
Questions? Reply to this email or visit the Help Centre
(https://acme.example/help).
--
Acme · 1 Harbour Street, Springfield
Notification settings: https://acme.example/preferences
sent to kim@example.com
The text part above is one half of the message. The other half, msg.HTML, is the table-based HTML email with the Acme logo, the pill button and the footer, and in Mailpit you can see both. Without a catcher, everything up to the send still runs, and the program exits at the last step with smtp: dial localhost:1025: … connection refused.
Define the data
Each template has a Go data type, and every merge tag in the template must resolve to a field of it.
type OrderShipped struct {
OrderID string `json:"order_id"`
ETA string `json:"eta"`
Total data.Money `json:"total"`
TrackURL string `json:"track_url"`
}
A merge tag matches a field by its JSON name, or by its lower-cased Go name when there is no json tag, so {{order_id}} is OrderID. A tag must resolve to a scalar: a string or integer kind, data.Money, data.Exact or any fmt.Stringer. data.Money holds an amount in minor units and a currency, and formats itself: data.USD(4250) renders as US$42.50.
Names your type does not have are looked up on the Recipient next. OrderShipped has no first_name, so {{first_name}} is the recipient's first name. See Merge tags and bindings for paths, fallbacks and structured values.
Write the template
The template is Markdown with YAML frontmatter. The program keeps it in a string constant; in a service it usually lives in a .md file embedded with //go:embed.
---
template: order.shipped
family: orders
subject: "Order {{order_id}} is on its way"
preheader: "Arriving by {{eta}}."
---
::statement{eyebrow="Order {{order_id}}"}
# On its way.
Hi {{first_name | there}}, your order will reach you by {{eta}}.
:button[Track delivery]{href="{{track_url}}"}
::
::breakdown
| Order total | {{total}} |
| --- | ---: |
| Delivery | Free |
::
::signoff
::
- The frontmatter names the template (it must equal the name passed to
Compile) and its family, and holds the subject and the preheader, the preview line an inbox shows after the subject. ::statementis the hero: an eyebrow, one heading, a paragraph and the email's one primary button.::breakdownis a two-column table. Its header row renders as an ordinary row.::signoffcloses the email with the default closing and name, which here read "Thanks," and "The Acme team".{{first_name | there}}falls back to "there" when the recipient has no first name. A merge tag with no value and no fallback is a render error.
Every tag is checked when the template compiles. Misspell {{eta}} as {{etaa}} and the program stops before main runs:
panic: order.shipped:11:57: unknown name "etaa"
The position is the line and column in the template source. The module catalogue lists every component you can use.
Describe a family
A family is a kind of email you send. The template names it in family: orders, and the family supplies everything the template does not: the sender, the stream, the header and footer, and the footer copy.
var families = lttr.NewRegistry(lttr.Family{
Name: "orders",
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,
})
Streamdecides whichSenderthe message goes through.Transactionalis mail the recipient triggered.H1is the logo header.F2is the service footer: a note, a "service email about …" line and the address.- The copy is Markdown with
{name}placeholders, filled per message:{site}fromConfig.SiteURL,{brand}from the theme,{address}from the recipient's region,{prefs}from the recipient's preferences URL or{site}/preferences. Merge tags are not allowed in copy. TextLinksare the URLs the text part prints under the footer, since plain text cannot link a phrase.
Compile applies the family's rules, such as the modules it forbids, so a template that breaks them fails at startup too. Families and streams covers every field.
Build the registry and renderer
lttr.NewRegistry collects the families, and MustCompile compiles the template against the registry and the data type:
var orderShipped = lttr.MustCompile[OrderShipped](families, "order.shipped", []byte(orderShippedSrc))
Keep it in a package-level var, so a broken template panics when the program starts rather than when the first order ships. Use Compile instead when the source comes from somewhere you don't control; it returns every error at once.
The renderer holds everything that is the same for every message: your domain and site, where the logo files live, the theme, the families and the legal entity per region.
r, err := lttr.NewRenderer(lttr.Config{
Domain: "acme.example",
SiteURL: "https://acme.example",
AssetBase: "https://cdn.acme.example/email",
Theme: brand,
Families: families,
Entities: map[string]lttr.Entity{"default": {Address: "1 Harbour Street, Springfield"}},
DefaultRegion: "default",
})
NewRenderer validates all of it and returns every problem at once: a SiteURL that is not absolute https, a colour that is not #RRGGBB, a sender outside the family's domain, footer copy with an unknown placeholder. The theme (brand in the program) sets both palettes, the three font stacks and the logo files, which are loaded from AssetBase; every colour must be set. A Renderer is immutable and safe for concurrent use, so build one and share it. See Config and the renderer and Themes.
Render and send
Render takes the renderer, the recipient and the data, and returns a *lttr.Message:
msg, err := orderShipped.Render(r,
lttr.Recipient{Address: "kim@example.com", FirstName: "Kim"},
OrderShipped{
OrderID: "A-1042",
ETA: "5:30 pm",
Total: data.USD(4250), // US$42.50
TrackURL: "https://acme.example/orders/A-1042",
})
Render checks everything that depends on the data: a merge tag with no value and no fallback, a URL that is invalid or uses a scheme that is not allowed, an empty subject, a recipient with no entity address, HTML of 100 KB or more (Gmail clips at 102 KB). It returns either a complete message or an error, never a partial message. Non-fatal findings, such as HTML over 90 KB, are in msg.Warnings.
The message carries the envelope (From, To, Subject), the stream, and both bodies (HTML, Text). A Router sends it through the Sender for its stream:
router := <tr.Router{Streams: map[lttr.Stream]lttr.Sender{
lttr.Transactional: <tr.SMTP{Addr: "localhost:1025"},
}}
if err := router.Send(context.Background(), msg); err != nil {
log.Fatal(err)
}
SMTP uses STARTTLS when the server offers it, and authenticates when you set Auth. It writes the message as multipart/alternative, text part first. If you deliver another way, msg.MIME(time.Now()) returns the raw bytes.
Read next
| If you want to | Read |
|---|---|
| Learn what a template can contain | Comark syntax |
| Pick modules for your email | Modules |
| Understand every compile error | Compile errors |
| Define your own families and theme | Your own family and theme |
| Send marketing, security and internal mail | Router and streams |
| See nine complete emails | The Quiet example |