Shipping Mail Merge: from a one-off CLI to a self-serve web product
Today I’m shipping Mail Merge — a self-serve web app for CSV-driven, plain-text mail merge over your own SMTP server. It’s MIT-licensed and the code is on GitHub. Here is a bit of background on how it came to be.
It started as a CLI script
Mail Merge began life as a single TypeScript file, send-credentials.ts. It did one narrow job: read a CSV of people, fill a plain-text template with each row’s values, and send one personalized email per row through an SMTP server. I wrote it because I needed to deliver a batch of email account credentials (I had migrated all users of an organization from Gandi to OVH).
The script was deliberately small. No web framework, no build step: Node runs the .ts file directly. It had three modes — a dry run that connects to nothing, a test mode that sends the whole batch to one address so you can proof it, and a live send — and it verified the SMTP connection before it sent anything, so a bad host or password failed fast instead of half-way through the list.
Why make a web app?
The script solved my problem, but the shape of the problem is common: someone has a spreadsheet, and each row needs its own email, sometimes with its own attachment. Credential delivery is one version of that, but the truth is every few months I need to do something of this shape, and I decided it was time to solve it once-and-for-all.
The choices I made
I kept the stack boring on purpose, because the interesting risk here is in the sending, not the framework.
- Next.js (full-stack) + Clerk auth. One codebase for the UI and the server-side actions. Clerk handles multi-tenant sign-in so I never store a password, and only the public pages are reachable without signing in.
- One SQLite file, in WAL (Write-Ahead Log) mode. No separate database server. The web app and a long-lived background worker share a single WAL-mode SQLite file on a Docker volume. WAL matters because it lets the worker write send progress while the web app reads it, without them blocking each other.
- A persistent Node worker for the actual sending. Batches don’t run inside a request. A separate worker process claims queued campaigns, sends one email at a time with a configurable throttle, and writes progress back to SQLite as it goes. That’s what makes live progress and mid-batch resume possible.
- BYO-SMTP, credentials encrypted at rest. Every user brings their own SMTP server. Those credentials are encrypted with AES-256-GCM before they touch the database, and the password is never logged, never returned to the browser, and never written into a receipt. The app fails loudly at startup if the encryption key is missing or the wrong length.
The per-recipient record
The single most important design decision is that every recipient in a campaign has its own durable record with a status.
A send isn’t “the batch went out”; it’s a state machine per row — queued, sending, sent, or failed.
That record is what gives you three things at once: live progress (count the statuses), an honest audit trail of exactly who received what, and safe resume. If the worker crashes or the VPS restarts mid-batch, the campaign picks up from the rows that aren’t yet marked sent, rather than starting over and double-sending.
It is at-least-once, not exactly-once — I’d rather be honest about that than pretend a distributed send can be perfectly transactional — but a recorded sent row is never sent again on resume.
The build process
I built this spec-first and AI-assisted, using GSD and Claude Code (Fable). And for what it’s worth, I left my .planning folder in the repo.
What it is now
The same merge-and-send engine has two kinds of front-ends: the web app for signed-in users, and a standalone CLI (which also exposes an MCP server for AI agents).
It does one thing — send one reliable, personalized email per row of your CSV, over your own SMTP, with a preview, a test-send, and a record of what happened — and it tries to do that one thing carefully.
Try it
The app is live at mailmerge.robindarlington.com, and the code is at github.com/robindarlington/mail-merge — self-hosting instructions included.
And if you have a spreadsheet-and-email process of your own that deserves to be a tool, get in touch — building these kinds of tools is exactly what I enjoy doing.