Skip to content

Versioning transactional email templates like code

Keep templates in the repo, review them like code, render them in tests and roll changes out safely so a typo never reaches a million inboxes.

Koltrix Team4 min read
Lines of HTML code in a dark editor
Photo by Florian Olivo on Unsplash
On this page(9 sections)
  1. Why templates drift out of control
  2. Templates in the repository
  3. Define the data contract explicitly
  4. Review templates like code
  5. Test rendering in CI
  6. Roll changes out safely
  7. Letting non-engineers edit
  8. Checklist
  9. Key takeaways

A transactional template is code that runs in a million inboxes you cannot patch. Once a receipt with a broken total or a password reset with a dead link has gone out, there is no rollback.

Treating templates with the same discipline as application code is the cheapest way to keep that from happening.

Why templates drift out of control

Most teams start with templates edited in a vendor dashboard. It is quick, marketing can tweak copy without a deploy, and nobody needs to touch the repo. Over time the problems pile up:

  • Nobody knows who changed the shipping confirmation last Tuesday, or why.
  • The template references a variable the application stopped sending three releases ago, so customers see an empty space where their order number should be.
  • Staging and production use different versions of the same template, and bugs only appear in one.
  • There is no way to see what a message looked like on a given date when a customer disputes it.

All of these are version control problems. The fix is to make templates live where your code lives.

Templates in the repository

Store each template as files next to the code that sends it. A layout that works well:

emails/
  layouts/
    base.html
    base.txt
  password-reset/
    subject.txt
    body.html
    body.txt
    fixtures.json
  order-receipt/
    subject.txt
    body.html
    body.txt
    fixtures.json

Each message type gets a folder with its subject, HTML body, plain-text body and a fixtures file containing realistic sample data. A shared layout handles headers, footers and styles so that a footer change happens in one place.

Pick a template language with a strict mode. Most engines (Handlebars, Jinja, Liquid, Go's html/template and others) can be configured to fail on undefined variables instead of rendering an empty string. Turn that on. A missing variable should break a test, not a customer's email.

Define the data contract explicitly

The most common template bug is a mismatch between what the template expects and what the application provides. Make the contract explicit:

{
  "order_id": "A-10293",
  "customer_name": "Sam Rivera",
  "items": [
    { "name": "Annual plan", "quantity": 1, "price": "$120.00" }
  ],
  "total": "$120.00",
  "receipt_url": "https://example.com/receipts/A-10293"
}

The fixtures file doubles as documentation. If your language supports it, define a typed struct or schema for each template's input and validate against it before rendering. Then a developer who renames total to amount_due gets a compile error or a failing test, not a support ticket.

Format money, dates and time zones in the application, not in the template. Templates that do arithmetic or date math become hard to test and easy to get subtly wrong.

Review templates like code

Once templates are in the repo, every change goes through a pull request. That gives you:

  • A diff showing exactly which words and markup changed.
  • A reviewer who can catch the broken link or the wrong merge field.
  • History you can search when someone asks what the cancellation email said in March.

Raw HTML diffs are hard to review visually, so generate previews automatically. A CI job can render every template with its fixtures and publish the HTML and text output as build artifacts, or post screenshots to the pull request. Reviewers then compare the rendered result, not just the markup.

Test rendering in CI

Rendering tests are cheap and catch most template bugs:

  1. Render every template with its fixtures. Fail on any undefined variable or syntax error.
  2. Snapshot the output. Store rendered HTML and text, and fail when they change unexpectedly. Updating a snapshot becomes a deliberate, reviewed act.
  3. Check links. Assert that every link is absolute, uses HTTPS and points at an allowed domain. Relative links do not work in email.
  4. Check the text part exists and is not just an empty string or the HTML with tags stripped.
  5. Check the subject is non-empty and under a sensible length.

A minimal snapshot test in Python might look like this:

import json, pathlib
from jinja2 import Environment, FileSystemLoader, StrictUndefined

env = Environment(loader=FileSystemLoader("emails"), undefined=StrictUndefined, autoescape=True)

def test_templates_render():
    for folder in pathlib.Path("emails").iterdir():
        if folder.name == "layouts":
            continue
        data = json.loads((folder / "fixtures.json").read_text())
        for part in ("subject.txt", "body.html", "body.txt"):
            out = env.get_template(f"{folder.name}/{part}").render(**data)
            snap = pathlib.Path("snapshots") / folder.name / part
            assert out == snap.read_text(), f"{folder.name}/{part} changed"

Note autoescape=True. Any value that comes from a user, such as a name or a company, must be escaped in HTML templates. Template injection in email is a real phishing vector.

Roll changes out safely

Deploying a template change is deploying code, so use the same safety tools:

  • Version identifiers. Include a template version in your send logs or as a custom header so you can tell which version a customer received.
  • Feature flags. For major redesigns, send the new template to a small share of traffic first and compare bounce, complaint and click behavior.
  • Fast rollback. Reverting a template should be a normal revert and redeploy, not an emergency dashboard edit.
  • Freeze windows. Avoid template changes right before high-volume events such as a renewal batch.

Letting non-engineers edit

Marketing and support often own the words in transactional email. Moving templates into the repo should not lock them out. Options that keep the discipline:

  • Keep copy in separate string files that non-engineers can edit through the code hosting web interface, with previews generated in the pull request.
  • Use a content system for copy blocks, but pin the version used in each release so production cannot change without a deploy.
  • Pair copy changes with a quick engineering review that only checks variables and links.

Checklist

  • Every transactional template lives in the repository with subject, HTML and text parts.
  • The template engine fails on undefined variables and escapes HTML by default.
  • Each template has a fixtures file that documents its data contract.
  • CI renders every template, snapshots the output and checks links.
  • Pull requests include rendered previews.
  • Send logs record which template version was used.
  • Rollback is a normal revert.

Key takeaways

  • Transactional templates cannot be recalled once sent, so they deserve code-level discipline.
  • Keeping templates in the repo gives you diffs, review and history for free.
  • Strict undefined-variable handling and explicit data contracts catch the most common bugs.
  • Rendering and snapshot tests in CI are cheap and effective.
  • Record template versions so you can answer "what did we send?" months later.

Start with Koltrix

Your domain, one inbox, and an API that sends.

A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.

SharePost on XLinkedIn