Skip to content
← Back to blog

Rotating Email Templates Without Breaking Them

7 min read
emailguidedevelopers

Go and read your own welcome email. Not the code that sends it — the text that arrives. There is a decent chance it walks a new customer through a three-step setup wizard you replaced in the spring, links to a docs page that now redirects, and signs off from a support address nobody reads. The receipt probably still says "Beta" in the footer.

This is the least-visited code in your product. You wrote it the week you launched, it worked, and it has been sending ever since — quietly, thousands of times, describing a version of your app that stopped existing. Nothing alerts on it. No test covers the prose. The one person guaranteed to read it closely is a brand-new customer forming a first impression, and they have no idea the paragraph confusing them is eighteen months stale.

The root cause is a category error. Most teams treat transactional email as settings — a thing you configure once, in a form, and then forget, the way you forget an S3 bucket policy. It is not settings. It is copy that ships, changes with the product, and needs the same review, versioning and rollback story as anything else you deploy. So: how to rotate templates deliberately, what versioning actually gives you, why rollback is not the button you think it is, and when all of this is too much ceremony for the size of your product.

Templates are artefacts, not settings

The practical form of that claim is simple: the source of truth for every template is a .hbs file in your repo, and the platform holds a copy. Not the other way around.

That one inversion buys you everything a settings page cannot. Copy changes arrive as diffs, in pull requests, where somebody can object to "we've decided to sunset" before it reaches ten thousand inboxes. What the welcome email said in March is git log, not a memory. A contributor finds the receipt wording by grepping, which is how they find everything else. And templates deploy with the release that changed the feature they describe, rather than three weeks later when someone notices.

The counter-argument is real: dashboards exist because non-developers need to edit copy, and making a marketer open a pull request to fix a typo is a bad trade. True. The resolution is not to pick one — it is to decide, per template, which side owns it. Lifecycle mail that describes product behaviour lives in the repo. Campaign copy someone rewrites weekly lives in the dashboard. What kills you is a template with two owners and no rule about who wins.

Rotation is a deliberate act

Once templates are artefacts, rotating them stops being scary and starts being a thing you schedule. Three patterns cover almost everything.

  • Seasonal variants. A December version of the receipt with a different header image and a line about holiday support hours. It is the same message with a different coat, and the important half is the calendar entry in January that takes the coat off.
  • An A/B pair. Two keys — say welcome and welcome-short — and a coin flip in your send code. Both are real templates, both are in the repo, and the loser gets deleted rather than left to rot as a mystery second welcome email.
  • A promotional wrapper. A layout, not a template: swap the shell for the campaign one and every message inherits the banner without touching a single body. Then swap it back. The highest-leverage rotation, and the easiest to forget, because nothing in any body ever changed.

The discipline is not in the rotation, it is in the un-rotation. Every temporary variant needs an expiry written down at the moment you create it. A promotional wrapper still running in February is worse than never having run it — it tells your customers, precisely and unmistakably, that nobody is looking.

Every import is a new version

Here is the mechanic that makes this safe. Importing a .hbs file against a key that already exists does not overwrite the template — it updates it in place and creates a new version. The previous content is retained, so your deploy script can push every template on every release without destroying what was live before it.

// deploy step: push every template, every release
for (const file of await readdir('emails')) {
  await helmdesk.emails.templates.import(
    await readFile(`emails/${file}`, 'utf8'),
    file,
    { type: 'template' },
  )
}

Unconditional, idempotent, and safe to run on every deploy — that is the whole rotation mechanism. Push the December variants in on the first, push the normal ones back in on the second of January, and each of those pushes is another version rather than a destructive edit. import_email_template does the same thing over MCP, which means "put the old receipt wording back" is a sentence you can say out loud to your assistant.

But be clear about what rollback means, because the word carries a promise it does not keep. There is no button that reverts to version 4. Rollback is importing the old source again — check out the previous file from git, import it, and you have a version 6 identical to version 4. The history is append-only, which is honest: what went out is preserved, including the bad one, and the fix is recorded as a fix rather than an erasure.

Two consequences. Your recovery path runs through your repo, so a template that only ever existed in the dashboard has no rollback at all — the strongest practical argument for the file-first rule. And deletion is the one genuinely destructive operation: deleting a template takes its versions with it, and any send naming the key afterwards fails outright. Grep for the key first.

Preview with real values, before anything ships

The failure that actually bites is not a broken template. It is one that renders perfectly with the variables you had in your head and produces Hi , for the customer who signed up with no name, or a total of undefined for the one plan whose price your code passes as a string.

So the rule is: nothing ships without being rendered with real values first. get_template_schema tells you the exact variable names a body references, which stops the guessing — you find out that the receipt wants invoiceNumber and your code has been sending invoice_number before a customer does. preview_email renders the thing, HTML and text, and sends nothing and writes nothing to the email log, so there is no reason to be sparing with it.

Then rehearse the send. A sandbox key renders and logs mail without ever delivering it, so the December variant can go through your real send path — real customer record, real variables, real layout — and you read exactly what would have arrived. Do that once and you will find the empty-name case, because there is always an empty-name case.

When this is over-engineering

Honestly: one developer with four templates and no seasonal anything does not need most of this. Keep the files in the repo, preview before you ship, skip the rest. The version history is there if you ever want it, and you will not have built a release process for a welcome email.

It starts to matter at three moments. When a second person can edit copy, because "who changed this" is suddenly a question. When you run more than one app, because four apps means four sets of the same four emails and drift is guaranteed. And when a template's wording has consequences — a charge, a refund window, a cancellation — because that is the day you need to prove what a customer was told in March, and "the current version says" is not an answer.

Your transactional email is the only part of your product that talks to customers while you sleep. It deserves to know what year it is.

Handlebars templates, versioned, previewed, and sent on your own SMTP

Import .hbs files straight from your repo, render them with real variables before anything ships, and keep every version you sent.