How Listmonk Handles Template Rendering with Go Templates: Complete Technical Guide
Listmonk compiles stored email template strings into Go's html/template and text/template engines at send time, executing them against subscriber data through a pipeline that auto-escapes HTML, converts Markdown, and injects custom helper functions.
The open-source newsletter manager knadh/listmonk treats every stored email template as a Go template. When campaigns or transactional messages are triggered, listmonk retrieves template strings from PostgreSQL and passes them through a sophisticated compilation pipeline before execution.
The Three-Step Template Rendering Pipeline
Listmonk's rendering process follows a strict three-phase architecture defined across models/templates.go and models/campaigns.go.
Step 1: Detecting Template Expressions
Before compilation, listmonk checks if a string requires template processing. The helper function hasTplExpr in models/campaigns.go (lines 44-48) scans for the presence of both {{ and }} delimiters, returning true only if the content contains actual Go template syntax.
Step 2: Compiling Template Objects
Listmonk distinguishes between transactional templates (reusable stored templates) and campaign templates (message-specific compositions).
Transactional Templates (models/templates.go):
The (*Template) Compile method processes the body using html/template for automatic HTML escaping, while subjects use text/template since they contain plain text.
Campaign Templates (models/campaigns.go):
The (*Campaign) CompileTemplate method constructs a base template named "base" containing the HTML layout, and a content template named "content" containing the message body. These are merged using AddParseTree to embed the content into the base layout's {{ template "content" . }} placeholder.
Step 3: Executing Against Subscriber Data
During message generation, listmonk calls Execute() on the compiled template, passing a map[string]any containing subscriber fields ({{ .Email }}, {{ .Name }}), campaign metadata, and custom variables.
How Transactional Templates Are Compiled
In models/templates.go, the Compile method handles stored templates from the database:
func (t *Template) Compile(f template.FuncMap) error {
// Compile HTML body with html/template (auto-escaping)
tpl, err := template.New(BaseTpl).Funcs(f).Parse(t.Body)
if err != nil {
return err
}
t.Tpl = tpl
// Compile subject line with text/template (plain text)
if hasTplExpr(t.Subject) {
subjTpl, err := txttpl.New(BaseTpl).Funcs(txttpl.FuncMap(f)).Parse(t.Subject)
if err != nil {
return err
}
t.SubjectTpl = subjTpl
}
return nil
}
This design ensures that HTML bodies are auto-escaped to prevent XSS attacks, while subject lines remain unescaped plain text.
How Campaign Templates Are Compiled
Campaign rendering in models/campaigns.go is more complex, handling Markdown conversion, alternate bodies, and header templating.
Base and Content Template Architecture
The CompileTemplate method creates two distinct template objects and merges them:
baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(body)
if err != nil {
return err
}
msgTpl, err := template.New(ContentTpl).Funcs(f).Parse(body)
if err != nil {
return err
}
out, err := baseTPL.AddParseTree(ContentTpl, msgTpl.Tree)
if err != nil {
return err
}
c.Tpl = out
The base template contains the overall layout with headers, footers, and CSS, while the content template holds the message body.
Markdown Processing
If ContentType is "markdown", listmonk converts Markdown to HTML using markdown.Convert during the CompileTemplate phase (lines 73-80) before template compilation occurs.
Header and Alt-Body Templating
Listmonk compiles additional template objects for email headers and plain-text alternatives:
- Header values: Compiled into
text/templateinstances stored inc.HeaderTpls(lines 112-138) - Alternate plain-text body: Compiled into
c.AltBodyTplif theAltBodyfield contains template expressions
Custom Template Functions and Helpers
Listmonk provides a rich template.FuncMap built in internal/core/core.go that exposes functions like fmtDate, lower, upper, and internationalization helpers (c.i18n.Ts) to template authors. This function map is passed to every Compile and CompileTemplate call.
Example usage in templates:
{{ .Name | upper }}
{{ fmtDate .CreatedAt "2006-01-02" }}
Security: HTML Escaping vs Plain Text
Listmonk carefully selects template engines based on content type to balance security and functionality:
html/template: Used for message bodies to automatically escape dangerous HTML entitiestext/template: Used for subject lines, headers, and plaintext alternatives where HTML escaping would corrupt content
Summary
- Listmonk stores raw template strings in PostgreSQL and compiles them at send time using Go's standard
html/templateandtext/templatepackages. - The
hasTplExprhelper inmodels/campaigns.godetects whether compilation is necessary by checking for{{and}}delimiters. - Transactional templates compile via
(*Template) Compileinmodels/templates.go, usinghtml/templatefor bodies andtext/templatefor subjects. - Campaign templates use
(*Campaign) CompileTemplateinmodels/campaigns.goto merge base layouts with content templates viaAddParseTree. - Markdown content is converted to HTML before template compilation when
ContentTypeequals"markdown". - Custom functions from
internal/core/core.goprovide date formatting, string manipulation, and internationalization capabilities.
Frequently Asked Questions
How does listmonk prevent XSS attacks in email templates?
Listmonk uses Go's html/template package for all HTML email bodies, which automatically escapes variables inserted via {{ .Variable }} syntax. This prevents malicious script injection from subscriber data while preserving intentional HTML from the template itself.
Can I use Markdown in listmonk campaigns?
Yes. When a campaign's ContentType is set to "markdown", listmonk converts the Markdown to HTML using markdown.Convert during the CompileTemplate phase (lines 73-80 of models/campaigns.go) before passing the result to the template compiler.
What custom functions are available in listmonk templates?
Listmonk injects a custom FuncMap built in internal/core/core.go that includes formatting functions like fmtDate, lower, and upper, plus internationalization helpers. These functions are available in both campaign and transactional templates.
Why does listmonk use different template engines for subjects and bodies?
Subjects, headers, and alternate plain-text bodies use text/template because they contain plain text where HTML entities would be corrupted (e.g., " appearing literally in a subject line). Message bodies use html/template to ensure proper HTML escaping and prevent XSS vulnerabilities.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →