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/template instances stored in c.HeaderTpls (lines 112-138)
  • Alternate plain-text body: Compiled into c.AltBodyTpl if the AltBody field 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 entities
  • text/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/template and text/template packages.
  • The hasTplExpr helper in models/campaigns.go detects whether compilation is necessary by checking for {{ and }} delimiters.
  • Transactional templates compile via (*Template) Compile in models/templates.go, using html/template for bodies and text/template for subjects.
  • Campaign templates use (*Campaign) CompileTemplate in models/campaigns.go to merge base layouts with content templates via AddParseTree.
  • Markdown content is converted to HTML before template compilation when ContentType equals "markdown".
  • Custom functions from internal/core/core.go provide 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →