Building a Local Script for Marketing Email Migrations
May 21, 2026 (8d ago)
We're migrating marketing automation platforms, with Customer.io as the new home. Which means migrating every email along with it.
A migration like this usually means rebuilding every email by hand, one at a time. It takes real time. It pulls attention away from other high-priority work. And we were doing this on top of a full refresh of our email template, so it wasn't just "move the emails." Every email needed to land in the new template on its way over.
With Claude already part of how we ship, I knew there was a better way. So we built a script that does the whole migration end-to-end. Today the first wave ran: 44 emails, five seconds.
This post is about that script. I expected a quick build. It got more involved than that.
From re-skin to native components
The first version of the script was a re-skin. Read a source HTML file, parse the content, render it back out in the new template. Visual fidelity. Wrong target.
Customer.io's Design Studio isn't an HTML-paste tool. It's a component-based authoring environment. Drop in raw HTML and you get a single opaque block that no one can tweak in the visual editor. We'd be moving emails into Customer.io without actually using Customer.io. The output needed to land in Design Studio as native components: Button, Header, Paragraph, Bullet List, and 12 more. Each block editable in the visual editor instead of raw HTML.
That meant a second renderer alongside the first one. The script now produces two outputs per email:
output/html/*.rendered.html: the raw HTML version, useful for visual review and as a fallbackoutput/cio/*.cio.html: the Customer.io authoring markup with all 16 of our custom components
Both come from the same parsed source, so any improvement to how the script reads the original email helps both outputs at once.
What the pipeline does today
For each source email, in one command, the script:
- Parses subject, preheader, and content blocks (headers, body, lists, CTAs, images, quotes) out of the source HTML
- Rewrites UTM params so analytics segments the new traffic properly
- Rehosts images by downloading them from the source CDN and uploading to Customer.io's asset library, so the migrated emails are self-contained going forward
- Renders both the raw HTML and Customer.io component markup
- Pushes each email to Design Studio via the Customer.io API, with sender, reply-to, subject, and preheader all set automatically
End to end, today's 44-email batch ran in about 5 seconds.
How Customer.io's new CLI and in-app agent shaped the workflow
Customer.io shipped two things that made this project move fast: their official CLI (@customerio/cli), launched 6 days ago, and an in-app AI agent that can answer API questions, search docs, hit endpoints, and take action directly inside the platform on your behalf. Both became core to the workflow.
The CLI handles auth in a single command:
npx -p @customerio/cli cio auth loginThat kicks off a browser-based SSO flow against our workspace and persists a short-lived access token to ~/.cio/config.json. My script reads the access token from that file at runtime, so credentials never touch the repo or the terminal. Tokens expire after a few hours, so mid-project I re-authed a couple of times. Easy enough.
The CLI is also a great source of truth for the API itself. Running cio schema dumps the full endpoint structure, which is how I landed on the right Design Studio path: https://us.fly.customer.io/v1/environments/{id}/ds/nodes. Regional host, environment-scoped path. Worth knowing if you're hitting Design Studio APIs from a script.
The in-app agent paired naturally with the CLI. Anytime I needed to get oriented on an unfamiliar surface, the agent got me moving quickly. And when an ask was easier done than explained, the agent could execute it directly in the platform on my behalf, no UI click-through required. The workflow that clicked: use the agent to scope an approach (or just hand it the task), then confirm by GET-ing an email I'd manually configured in the UI and diffing against my POST body. Five minutes of that loop unblocked nearly every API question I had.
A few patterns from the project worth sharing:
/assets/uploadfor image rehosting. Customer.io has a one-shot endpoint that handles signing, uploading, and registering in a single POST. Clean way to move assets between systems.envelope_detailsfor envelope metadata. The wrapper for subject, From, and Reply-To uses snake_case with camelCase fields inside. Easy to confirm by GET-ing a manually-configured email.- Plain
<li>children for bullet lists. Customer.io's bullet-list component takes standard<li>children directly. No slot or template wrapping needed.
Idea to first production run was less than a week. I had the idea last Thursday, built in between other projects over the next few days, ran the bulk push today.
A useful dark mode pattern
Customer.io's Design Studio supports dark mode previews, and our brand has both light and dark variants for everything. The components themselves carry light/dark color pairs internally (a sun and moon icon next to each color field in the editor), but the email-level wrapper doesn't get a dark variant unless you explicitly set one on the section.
The first imports came in with white text on a white background in dark mode. The components themselves were swapping their colors, but the wrapper around the whole email wasn't. The fix was setting an outer-background attribute on the <x-section> element with both light and dark colors:
<x-section width="640px" :outer-background="{ light: `#FFFFFF`, dark: `#0A0A0A` }">The syntax was a bit unusual (backticks inside double quotes), and getting the escaping right inside a TypeScript template literal took a couple of tries. But with that attribute on every section, dark-mode wrapping just works going forward.
What's automated, what's manual
After all the work, here's what the script handles without human intervention on a push:
- From address (Customer.io workspace identity reference, currently
ship@info.vercel.com) - Reply-To (separate identity,
no-reply@info.vercel.com) - Subject line (extracted from the source HTML's
<title>tag when populated) - Preheader (extracted from the source HTML's hidden preview div)
- Image rehosting and URL substitution
- UTM rewriting
- Component-level dark mode variants
- Section-level dark mode background
- v0 vs Vercel logo selection (filename heuristic)
- Authoring markup with all 16 of our components
What stays manual, by choice:
- Final visual review per email
- Publishing each email to a campaign
The migration itself is the boring part now: drop in the source HTML, run the script, QA in Design Studio, ship. Today's 44 emails landed in five seconds. Beyond this batch, the CLI itself is the bigger unlock. I spend a lot of my day in the terminal, so having Customer.io reachable from there matches how I already work.
The interesting part isn't just the migration script. It's how a new platform fits into our end-to-end ops. A lot of our recurring ops work used to mean clicking through a UI. Those same tasks can now run from a terminal or get triggered from Slack through mOperator. When the platforms we run on are scriptable from the surfaces we already live in, the shape of our team's work changes.
Migrations were the first piece. Reshaping how the team operates day to day is the real project.