How I moved from SaaS vendor lock-in to templates-as-code with feature-flagged gradual rollout, CI/CD automation, and actually enjoyed managing emails again
The Beginning: Why We Even Chose MailerSend
About two years ago, when we were just getting TranqBay Health off the ground, we needed to get transactional emails working fast. You know how it is at the start of a project - you just need something that works. MailerSend seemed perfect: nice visual template builder, drag and drop, preview right in the browser. We could knock out a welcome email in 20 minutes. Ship it and move on.
And honestly? It was great. For the first few months.
We’d add a new feature - “Oh, we need a booking confirmation email!” - jump into MailerSend, clone an existing template, tweak it, done. Repeat this about 50 times over the next year and a half.
When Things Started Getting… Complicated
Fast forward to about six months ago. We had over 60 email templates in MailerSend. And that’s when the cracks started showing.
Problem #1: The “No Dev Environment” Nightmare
Here’s the thing about MailerSend - there’s no concept of dev/prod environments within an account. Every template edit goes straight to production. So every time I wanted to edit a template, I had this moment of panic: “Wait, if I click save, does this go straight to production?”
Spoiler alert: Yes. Yes it does.
The workaround? Duplicate every template. Have “Booking Confirmation - Test” for dev and “Booking Confirmation” for prod. Then manually keep them in sync. With 60 templates, that means 120 templates to manage. And if we wanted a proper dev/prod setup with separate accounts? We’d need to manage two MailerSend accounts and duplicate all templates across both.
It was becoming kubasa (cumbersome).
Problem #2: The Day We Needed to Bulk Edit
This is where things got really painful. We realized that for HIPAA compliance, we needed to change some of the language in our emails. Specifically, we needed to change “mental health” to “essential” or “wellness” across multiple templates.
“No problem,” I thought. “I’ll just do a find and replace.”
Except… MailerSend doesn’t have bulk editing. Or find/replace. Or ANY way to edit multiple templates at once.
So my options were:
- Open template 1 → Click edit → Find the text → Change it → Save
- Open template 2 → Click edit → Find the text → Change it → Save
- Repeat 58 more times
- Cry
I actually started doing this. Got through maybe 10 templates before I thought, “There HAS to be a better way.”
Problem #3: We Were Managing TWO Email Systems
Oh, and here’s the kicker - we were hitting rate limits with MailerSend when sending bulk campaign emails to our user list. We ended up adding Listmonk + AWS SES for our newsletter and campaign emails.
Which meant we were now paying for MailerSend ($$) AND managing Listmonk. Two systems. Two places to update. Two different template syntaxes to remember.
Problem #4: There’s No Easy Export
You know what’s wild? MailerSend can export your template metadata (IDs, names, subjects) but not the actual HTML content. So if you want to migrate away with your templates intact, you have to:
- Open each template in your browser
- Click “Edit”
- Copy the HTML
- Paste it somewhere
- Do this 60 more times
I’m not making this up. This was my Tuesday afternoon.
The Lightbulb Moment
So there I was, copy-pasting my 11th email template, when it hit me: “Wait… we already HAVE Listmonk running for campaigns. And Listmonk can do transactional emails too.”
I pulled up the Listmonk docs. Five minutes later I was like, “Why aren’t we using this for everything?”
Look at what we’d get:
- Templates as actual HTML files - not locked in some SaaS platform
- Real version control -
git log
to see who changed what and when - Actual dev/prod environments - we already have Listmonk running in dev and prod!
- API for everything - create templates, update them, sync them, all via API
- Free (mostly) - we’re already hosting it, and AWS SES has a generous free tier
- One system instead of two - campaigns AND transactional emails in one place
The only question was: How do we migrate 60 templates without breaking production?
Building the Solution (Or: How I Learned to Stop Worrying and Love Templates-as-Code)
Part 1: Fixing the “Copy-Paste 60 Templates” Problem
Okay, so first challenge: getting all these templates out of MailerSend and into files. Remember, no export API. Just me, a browser, and a lot of Ctrl+C, Ctrl+V.
I started doing this manually. Opened template 1, copied HTML, pasted into a file. Template 2, same thing. Around template 5, I thought, “This is going to take forever.”
Then I remembered: we have Claude Code.
So I tried something. I copied a MailerSend template, opened Claude, and said:
“Hey, convert this MailerSend template to Listmonk format. Use
{{ .Tx.Data.variable_name }}
for variables and extract the header/footer into separate files.”
And you know what? It worked. Like, really well. Claude understood the template structure, converted the syntax, and even suggested breaking things into partials (reusable components).
What should have taken me two weeks of tedious work took about… a week? And most of that was reviewing the output and testing.
Part 2: The Partials System (Never Repeat Yourself Again)
Here’s what I realized while doing this migration: every single one of our 60 templates had THE SAME header, footer, and CSS. Copy-pasted 60 times.
So I set up a simple partials system with <!-- INCLUDE: partials/footer.html -->
comments that get replaced during build:
npm run templates:build
This command does the magic:
// src/commands/build-templates.command.ts (simplified)
@Command({ command: 'templates:build' })
async buildTemplates() {
// Load all partials into memory
const partials: Record<string, string> = {};
partialFiles.forEach((file) => {
partials[`partials/${file}`] = fs.readFileSync(partialPath, 'utf-8');
});
// Process each template
sourceFiles.forEach((file) => {
let content = fs.readFileSync(sourcePath, 'utf-8');
// Replace <!-- INCLUDE: path --> with actual content
content = content.replace(
/<!--\s*INCLUDE:\s*([^\s]+)\s*-->/g,
(match, partialPath) => partials[partialPath] || match
);
fs.writeFileSync(outputPath, content, 'utf-8');
});
}
It reads all the partial files, then goes through each template and replaces <!-- INCLUDE: partials/footer.html -->
with the actual footer HTML.
The payoff?
Remember that HIPAA compliance issue where I needed to change “mental health” across multiple templates? Now I can do:
# Edit footer partial
nano templates/partials/footer.html
# Find and replace in one file
# Build templates
npm run templates:build
Boom. All 60 templates updated. No clicking through browser UIs. No copy-pasting. Just one edit.
Part 3: The “Never Manually Update Templates Again” System
Okay, so now I have templates as files. Great. But how do I get them into Listmonk without manually uploading 60 files every time something changes?
This is where ArgoCD (our deployment tool) comes in. I set up a Kubernetes Job that runs automatically after every deployment:
# helm/environments/dev/templates/template-sync-job.yaml (simplified)
apiVersion: batch/v1
kind: Job
metadata:
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "1"
spec:
ttlSecondsAfterFinished: 3600
template:
spec:
containers:
- name: template-sync
command:
- /bin/sh
- -c
- |
npm run templates:build
npm run console sync:templates
envFrom:
- configMapRef:
name: {{ include "sendstream.fullname" . }}-configmap
Sync Command (sync:templates
):
// src/commands/sync-templates.command.ts (simplified)
@Command({ command: 'sync:templates' })
async syncTemplates() {
const templatesIndex = JSON.parse(fs.readFileSync('templates-index.json'));
for (const template of templatesIndex) {
const templateBody = fs.readFileSync(template.fileName, 'utf-8');
const exists = await this.getTemplate(template.id);
exists
? await this.updateTemplate(template.id, { name: template.name, body: templateBody })
: await this.createTemplate({ name: template.name, body: templateBody, type: 'tx' });
}
}
Template Index (templates-index.json
):
[
{
"id": 8,
"name": "Unpaid Voucher Reminder",
"type": "tx",
"fileName": "8-unpaid-voucher-reminder.html",
"subject": "Reminder: Complete Your Voucher Payment"
},
{
"id": 30,
"name": "Booking Awaiting Confirmation",
"type": "tx",
"fileName": "30-booking-awaiting-confirmation.html",
"subject": "Your Booking with {{ .Tx.Data.provider_name }} is Awaiting Confirmation"
}
// ... 58 more templates
]
So the workflow becomes:
- I edit a template file on my laptop
- Commit it to git:
git commit -m "Update booking email"
- Push to dev branch:
git push origin dev
- ArgoCD sees the change, deploys to dev, runs the sync job
- Templates in dev Listmonk are now updated
When I’m ready for production, I merge to main, and the same thing happens but with prod Listmonk.
No more:
- Opening MailerSend in a browser
- Finding the template
- Hoping I’m editing the right environment
- Praying I don’t break production
Just:
git commit && git push
And it’s done. This alone was worth the migration.
Part 4: The “Don’t Break Production” Problem (aka Feature Flags Save the Day)
Here’s where it gets interesting. I’ve got 60 templates migrated and synced. But how do I switch from MailerSend to Listmonk without, you know, breaking all our production emails?
I can’t just flip a switch. What if something’s wrong? What if I messed up a template? What if Listmonk goes down?
Enter: Feature flags with Flagsmith.
The idea: Keep both email systems running (MailerSend and Listmonk), but use a feature flag to control which one actually sends each email. That way I can test one email type at a time in production, gradually rolling out until we’re confident everything works.
Here’s how I built it:
// src/email/email-delegate.service.ts (simplified)
@Injectable()
export class EmailDelegateService {
private readonly CACHE_TTL = 60000; // 1 minute cache
private async getServiceForEvent(eventName: string) {
// Check Flagsmith with email_type trait for per-email routing
const useV2 = await this.flagsmithService.isFeatureEnabled('use_email_v2', {
email_type: eventName,
});
return useV2 ? this.emailServiceV2 : this.emailService;
}
@OnEvent('booking.confirmed')
async bookingConfirmed(data: EventPayloads['booking.confirmed']) {
const service = await this.getServiceForEvent('booking.confirmed');
return service.bookingConfirmed(data);
}
// ... more event handlers for all 60+ email types
}
This creates an “email delegate” that sits between my application and the email services. Every time we need to send an email, it checks Flagsmith: “Should I use MailerSend or Listmonk for this email?”
The magic happens in the Flagsmith service:
// src/flagsmith/flagsmith.service.ts (simplified)
@Injectable()
export class FlagsmithService {
async isFeatureEnabled(
featureName: string,
traits?: Record<string, string | number | boolean>,
): Promise<boolean> {
if (traits) {
// Use identity-based flags for per-email-type routing
const identifier = `email_routing_${featureName}`;
const flags = await this.flagsmith.getIdentityFlags(identifier, traits);
return flags.isFeatureEnabled(featureName);
} else {
// Use environment flags for global checks
const flags = await this.flagsmith.getEnvironmentFlags();
return flags.isFeatureEnabled(featureName);
}
}
}
Part 5: The Listmonk Service Integration
Now I needed to actually send emails through Listmonk:
// src/listmonk/listmonk.service.ts (simplified)
@Injectable()
export class ListmonkService {
public async sendEmail(personalization: ListmonkPersonalization) {
await this.axiosInstance.post('/tx', personalization);
}
}
The Migration Story (Week by Week)
Week 1: Dev Environment
First, I tested everything in dev. Set the global flag to use_email_v2 = true
for the dev environment in Flagsmith.
Went through each of the 60 email types, testing them one by one. Fixed a few syntax issues here and there (turns out I had a typo in template 23 🤦).
Week 2: Production… but Carefully
This is where it got real. I was about to start routing production emails through a system we’d just migrated.
Started conservative:
- Global flag:
use_email_v2 = false
(everything still uses MailerSend) - Created a segment for just voucher purchase emails (low volume, low risk)
In Flagsmith Dashboard:
Segment: "Test Listmonk - Voucher Emails"
Rule: email_type = "unpaid.voucher.reminder"
Override for this segment: use_email_v2 = true
Watched the logs nervously for a few hours. Checked that emails were actually being delivered. Asked the team to buy a test voucher and verify the email looked right.
Everything worked! 🎉
Week 3-4: Gradually Enabling More Emails
Emboldened by success, I started adding more email types:
Week 3:
Segment: "Booking Confirmation Emails"
Rule: email_type = "booking.confirmed"
Override: use_email_v2 = true
Week 4:
Segment: "All Booking Emails"
Rule: email_type CONTAINS "booking"
Override: use_email_v2 = true
For high-volume emails like payment.completed
, I did a percentage rollout:
Segment: "Payment Emails - 50% Rollout"
Rule: email_type = "payment.completed"
Percentage Split: 50%
Override: use_email_v2 = true
This sent 50% through Listmonk, 50% through MailerSend. Perfect for comparing deliverability and catching any issues.
The Ongoing Rollout
We’re still in the gradual rollout phase. After weeks of testing individual email types with segment overrides, we’ve built confidence in the system. The plan is to eventually flip the global flag once we’ve validated all email types in production.
The beautiful thing? If anything goes wrong at any point, we can:
- Open Flagsmith
- Flip
use_email_v2
back tofalse
- Wait 60 seconds (cache TTL)
- Back to MailerSend
No code changes. No deployments. Just a toggle. This safety net is what makes the gradual migration possible.
What This Actually Looks Like in Practice
Let me show you what my workflow looks like now compared to before:
Before (MailerSend):
// Boss: "Hey, can you update the booking confirmation email?"
// Me: *internal screaming*
1. Log into MailerSend
2. Find "Booking Confirmation" template (scroll scroll scroll)
3. Click "Edit"
4. Make the change in the browser editor
5. Preview it (looks fine)
6. Click "Save"
7. Realize I just deployed to production
8. Hope nothing broke
9. Get message: "The email looks weird on mobile"
10. Repeat from step 1
Now (Listmonk + Git):
# Boss: "Hey, can you update the booking confirmation email?"
# Me: Sure!
# Edit templates/src/38-booking-confirmation.html
npm run templates:build && npm run preview:templates
# Looks good!
git commit -m "Update booking confirmation copy"
git push origin dev
# Auto-deploys to dev, syncs templates
# Test it in dev environment
# Looks good!
git push origin main
# Auto-deploys to production
# Done ✅
Total time: 5 minutes. Zero anxiety about breaking production.
Complete Code Examples
Email Service V2 Implementation
// src/email/email.service.v2.ts (simplified)
@Injectable()
export class EmailServiceV2 {
async bookingConfirmed(data: EventPayloads['booking.confirmed']) {
const { client, provider, appointmentTime } = data.booking;
await this.listmonk.sendEmail({
subscriber_email: client.email,
template_id: 38,
data: {
name: client.firstName || client.userName,
provider_name: `${provider.firstName} ${provider.lastName}`,
date: formatDateInTimezone(appointmentTime, client.timezone),
},
content_type: 'html',
});
}
}
Template with Conditionals
<!-- templates/src/30-booking-awaiting-confirmation.html -->
<h1>Booking Awaiting Confirmation</h1>
<p>Hello {{ .Tx.Data.name }},</p>
{{ if .Tx.Data.is_free }}
<p>If approved, you'll need to confirm your booking.</p>
{{ else }}
<p>If approved, you'll need to complete payment ({{ .Tx.Data.fee }}).</p>
{{ end }}
Is It Worth It?
Absolutely. Here’s what actually changed:
The Money Part
We went from paying for MailerSend to basically no marginal cost per month. AWS SES has a generous free tier that covers our volume. Listmonk runs on our existing Kubernetes cluster, so there’s no additional infrastructure cost.
The Time Part
Remember that HIPAA compliance change I mentioned? The one where I needed to change “mental health” to “wellness” across 60 templates?
Before: Would have taken me a full day, clicking through 60 templates, hoping I didn’t miss any.
After:
grep -r "mental health" templates/
# Edit templates/partials/footer.html and change it once
npm run templates:build
git commit && git push
Time: 2 minutes. The build and deploy takes longer than the actual work.
And that’s just one example. Every template change is now:
- Faster (no UI to navigate)
- Safer (can test in dev first)
- Trackable (git history shows who changed what and why)
- Reversible (git revert if something breaks)
The Sleep Part
This might sound dramatic, but I sleep better knowing that:
- I can’t accidentally break production - dev and prod are actually separate
- I can roll back instantly - flip a feature flag, wait 60 seconds
- Someone else can fix it - templates are code, anyone can edit them
That peace of mind? Priceless.
Key Technical Achievements
1. Template Partials System
- Before: 60 monolithic HTML files
- After: 60 source files + 3 shared partials (head, header, footer)
- Impact: Update footer once, affects all templates
2. CI/CD Template Sync
- Before: Manual template updates in web UI
- After: Git commit → ArgoCD deploy → auto-sync
- Impact: Dev and prod always in sync
3. Zero-Downtime Migration
- Before: All-or-nothing switch
- After: Gradual rollout with feature flags
- Impact: Tested each email type individually in production
4. Developer Experience
# Make a change to templates/src/30-booking-awaiting-confirmation.html
# Build and preview locally
npm run templates:build
npm run preview:templates
# Commit and push
git commit -m "Update booking confirmation email"
git push
# ArgoCD deploys and syncs automatically
# Feature flag controls which emails use new version
Infrastructure Details
ArgoCD Configuration
# Dev cluster watches dev branch
apiVersion: argoproj.io/v1alpha1
kind: Application
spec:
source:
targetRevision: dev
path: helm/environments/dev
destination:
namespace: dev
syncPolicy:
automated: { prune: true, selfHeal: true }
---
# Prod cluster watches main branch
spec:
source:
targetRevision: main
path: helm/environments/prod
destination:
namespace: production
Monitoring & Rollback
Monitoring
All routing decisions are logged:
[EmailDelegateService] Email routing for 'booking.confirmed': Listmonk
[ListmonkService] Email sent successfully to user@example.com
Rollback Strategy
- Disable in Flagsmith (60 seconds): Set
use_email_v2 = false
, wait for cache refresh - Remove segment override: Delete specific email type override in Flagsmith
- Restart service (immediate):
kubectl rollout restart deployment/sendstream
What I’d Do Differently (And What I’d Do the Same)
Do Again:
Feature flags for gradual rollout - This was clutch. Being able to test one email type at a time in production, with an instant rollback option, meant I could sleep at night during the migration. 10/10 would feature flag again.
Using Claude for template conversion - Seriously, this saved me. I’m not exaggerating when I say it cut the work from 2 weeks to less than a week. And the suggestions it made about extracting partials? Better than what I would have come up with.
Starting with low-volume, low-risk emails - Voucher purchase reminders were perfect. If something broke, it wouldn’t affect our core business (bookings). Build confidence with the easy stuff first.
Do Differently:
I should have migrated sooner - We’ve been paying for MailerSend for almost 2 years when we already had Listmonk running. That’s… a lot of money spent unnecessarily. Live and learn.
Should You Do This?
Look, I’m not going to say “everyone should migrate to Listmonk.” That would be silly. Every situation is different.
But consider it if:
- ✅ You’re already hosting some infrastructure (we were running Kubernetes anyway)
- ✅ You have more than ~20 email templates and they keep growing
- ✅ You’re frustrated with dev/prod separation (or lack thereof)
- ✅ You want to treat templates like code (git, reviews, rollbacks)
- ✅ You’re paying for multiple email services (transactional + campaigns)
- ✅ You need HIPAA/compliance and want data sovereignty
Stick with a SaaS if:
- ❌ You don’t want to manage infrastructure
- ❌ You’re happy with your current provider
- ❌ You have <10 templates and they rarely change
- ❌ The visual template builder is important to you
- ❌ You don’t have someone comfortable with DevOps/infrastructure
For us? 100% worth it. The time savings alone justified the effort, and the cost savings are a nice bonus.
Wrapping Up
This whole project - from “ugh, I need to copy-paste 60 templates” to “oh, everything’s in git and auto-deployed” - took about 5-6 weeks. Most of that was me being cautious with the rollout and also juggling a lot of other work.
If I did it again knowing what I know now? Probably 2-3 weeks.
The nice thing about this architecture is that it’s not all-or-nothing. You can:
- Start by just putting templates in files (even if you manually upload them)
- Add the build system when you need it
- Add CI/CD when you’re ready
- Use feature flags if you’re doing a migration
Each piece adds value on its own.
Note: I created this article using Mac Whisper for speech-to-text - I prefer talking over writing. Claude Code then helped extract the relevant code samples from the codebase and format everything.
ps: If you’re from MailerSend reading this - your product is good! This migration was more about our specific needs (dev/prod separation, bulk editing, cost) than any problems with your service. You’re great for getting started quickly. Just wasn’t the right fit for us at scale.