← Blog

Email automation

Mailchimp to Postmark: half-day swap for a publisher

The Mailchimp invoice was eleven hundred euros a month for a list that fit in a CSV. We swapped it for Postmark and a 90-line worker before lunch was over.

Jacob Molkenboer· Founder · A Brand New Company· 5 Jun 2026· 9 min
Brass postage scale holds cream envelope with green wax seal, leather blotter, tied letters, brass opener on ivory desk.

The Mailchimp dashboard was open on a Tuesday morning at a Dutch trade publisher's office in Utrecht. The marketing lead had been staring at the same screen for ten minutes. Eleven hundred euros a month, billed in dollars, for a list of forty-one thousand subscribers and one weekly newsletter. The audience growth was flat. The merge tags were the same as in 2018. Nothing in that screen had changed except the price.

She forwarded the invoice to ops with one line: is this really the floor? Ops forwarded it to us with two: half day. Just rip it out.

We did. This is the playbook.

Why Mailchimp gets ripped out, and why not

Mailchimp earns its keep at two ends of the market. At the small end, you get a passable drag-and-drop editor and the world's simplest signup form. At the very large end, you get audience segmentation, predictive scoring, and a sales team that returns your calls. The trouble is the middle. If your stack is a CMS, a CSV, and a developer who can write fifty lines of JavaScript, you are paying the enterprise price for the small-shop product.

The publisher in Utrecht sat squarely in that middle. One list. One weekly issue. Templates that had not moved in three years. Open rates that did not depend on Mailchimp's smarts because the audience was already opted in and already loyal.

So the question was not is Mailchimp bad. The question was is Mailchimp the right shape for this job. The answer was no.

The split that makes the swap easy

Most "I want to leave Mailchimp" posts fail at the same wall. They try to replace one product with one product. That is the wrong frame. You replace Mailchimp with two pieces:

  1. A sender. The thing that puts mail on the wire, handles DKIM signing, and reports bounces and complaints. Postmark, Resend, Amazon SES are all fine. We picked Postmark because the batch email API is humane and the separation between transactional and broadcast streams is enforced at the API level, which keeps you out of the shared-reputation trap.
  2. A list. A row in your own database with a status column. That is it. Active, unsubscribed, bounced, complained. Four states. One table.

Once you see the split, the value of a Mailchimp-shaped tool collapses. The editor? Your CMS already has one. The signup form? Fifteen lines on the marketing site. The segmentation? You have SQL. The deliverability magic? Postmark and SES have spent the last decade making sure that signed, authenticated mail with a clean suppression list reaches the inbox.

The architecture, on one page

Here is what we drew on a notepad before writing any code.

  • The CMS already stored issues as a newsletter content type. We added two fields: subject and preview_text. The body was rendered to HTML and plain text by an existing MJML pipeline.
  • Subscribers lived in a Postgres table with email, first_name, list, status, and created_at. The unsubscribe token was an HMAC of the address, so we never needed to store one.
  • A small worker, running on the Hetzner VM the publisher already owned, did three things. It picked up an issue when its send_at was reached, fanned the issue out to Postmark in batches of five hundred, and wrote one row to a sends table for each recipient.
  • A Postmark webhook posted bounces, complaints, and one-click unsubscribes back to a tiny HTTP endpoint that updated status on the subscriber row.

That is the whole picture. No queues. No retries beyond what the SDK does itself. No campaign object, because a row in the issues table is a campaign.

The worker, in ninety lines

This is the file. Node 20, the official Postmark SDK, and the Supabase client because that is what the publisher's stack already had. Swap the database client for whatever you use; the shape stays the same.

// worker.mjs - sends one newsletter issue to a list via Postmark.
// Usage: node worker.mjs <issue_id>
import { ServerClient } from 'postmark';
import { createClient } from '@supabase/supabase-js';
import { createHmac, timingSafeEqual } from 'node:crypto';
import http from 'node:http';

const pm = new ServerClient(process.env.POSTMARK_TOKEN);
const db = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_KEY);
const SECRET = process.env.UNSUB_SECRET;
const FROM = 'redactie@uitgever.nl';
const STREAM = 'broadcast';
const BATCH = 500;

const sign = (email) =>
  createHmac('sha256', SECRET).update(email.toLowerCase()).digest('hex').slice(0, 24);

const verify = (email, token) => {
  const a = Buffer.from(sign(email));
  const b = Buffer.from(token, 'utf8');
  return a.length === b.length && timingSafeEqual(a, b);
};

const unsubUrl = (email) =>
  `https://uitgever.nl/u/${encodeURIComponent(email)}/${sign(email)}`;

const fill = (tpl, s) => tpl.replaceAll('{{first_name}}', s.first_name ?? 'lezer');

async function sendIssue(issueId) {
  const { data: issue } = await db.from('issues').select('*').eq('id', issueId).single();
  const { data: subs } = await db
    .from('subscribers')
    .select('email, first_name')
    .eq('list', issue.list)
    .eq('status', 'active');

  for (let i = 0; i < subs.length; i += BATCH) {
    const chunk = subs.slice(i, i + BATCH);
    const batch = chunk.map((s) => ({
      From: FROM,
      To: s.email,
      Subject: issue.subject,
      HtmlBody: fill(issue.html, s),
      TextBody: fill(issue.text, s),
      MessageStream: STREAM,
      Headers: [
        { Name: 'List-Unsubscribe',
          Value: `<${unsubUrl(s.email)}>, <mailto:unsub@uitgever.nl>` },
        { Name: 'List-Unsubscribe-Post', Value: 'List-Unsubscribe=One-Click' },
      ],
      Metadata: { issue: String(issue.id), list: issue.list },
    }));
    const res = await pm.sendEmailBatch(batch);
    await db.from('sends').insert(res.map((r, k) => ({
      issue_id: issue.id,
      email: chunk[k].email,
      message_id: r.MessageID ?? null,
      error: r.ErrorCode ? r.Message : null,
    })));
  }
  await db.from('issues').update({ sent_at: new Date().toISOString() }).eq('id', issueId);
}

http.createServer(async (req, res) => {
  if (req.method === 'GET' && req.url.startsWith('/u/')) {
    const [, , raw, token] = req.url.split('?')[0].split('/');
    const email = decodeURIComponent(raw);
    if (!verify(email, token)) return res.writeHead(400).end('bad token');
    await db.from('subscribers').update({ status: 'unsubscribed' }).eq('email', email);
    return res.writeHead(200).end('You are unsubscribed.');
  }
  if (req.method === 'POST' && req.url === '/postmark-webhook') {
    let body = '';
    for await (const c of req) body += c;
    const evt = JSON.parse(body);
    const map = { HardBounce: 'bounced', SpamComplaint: 'complained',
                  SubscriptionChange: 'unsubscribed' };
    const status = map[evt.RecordType];
    if (status && evt.Email) {
      await db.from('subscribers').update({ status }).eq('email', evt.Email.toLowerCase());
    }
    return res.writeHead(200).end('ok');
  }
  res.writeHead(404).end();
}).listen(8080);

if (process.argv[2]) sendIssue(process.argv[2]).then(() => process.exit(0));

Three things are worth pointing out, because they are the parts you will get wrong the first time.

The batch endpoint is your friend. Postmark accepts up to five hundred messages per call. One round-trip sends to five hundred recipients. Forty-one thousand subscribers fan out in eighty-two requests. On a residential connection in Utrecht, the whole send finished in just under three minutes.

Per-recipient body, not BCC. Every message goes To one address. No BCC. No undisclosed-recipients trickery. That is what lets you personalise, that is what lets the inbox provider see clean envelope addresses, and that is what keeps your domain reputation intact.

The unsubscribe header is not optional. Gmail and Yahoo started enforcing RFC 8058 one-click unsubscribe for bulk senders in February 2024. If your headers are missing List-Unsubscribe and List-Unsubscribe-Post, your mail goes to spam. The worker writes both on every send.

Warning

If you send more than five thousand messages per day to Gmail or Yahoo addresses, your domain DMARC policy must be at least p=none with a reporting address, SPF and DKIM must align, and one-click unsubscribe must work in a single POST. Miss any of these and your bulk stream goes straight to junk. Test before you cut over, not after.

Authentication, set once and forgotten

Every domain we move to Postmark gets the same three DNS changes, in the same order, before any mail goes out.

  1. An SPF record (or an update to the existing one) that includes spf.mtasv.net. The publisher's existing Google Workspace include stays in place; Postmark sits next to it.
  2. Two DKIM CNAMEs that Postmark generates per server. Use the 2048-bit keys, not the legacy 1024.
  3. A Return-Path CNAME so that bounce processing happens on a subdomain of the publisher's own domain, not on a Postmark-branded one.

DMARC was already at p=quarantine on the publisher's domain. We left it alone. If you are starting from scratch, begin at p=none with rua= pointing at a monitoring inbox, run for two weeks, then ratchet up. Do not ship to p=reject on day one.

The cutover, step by step

The actual swap took three hours and twenty minutes. Here is what those minutes were.

Export, clean, import (0:00 to 0:45)

We exported the Mailchimp audience as CSV. Forty-one thousand rows. A one-off script lowercased every address, dropped rows where Mailchimp had a status other than subscribed, and normalised the first-name column (about a thousand rows stored as "Maaike " with a trailing space). The cleaned file went into the subscribers table with status='active' in one COPY statement.

DNS, then a sandbox send (0:45 to 1:30)

SPF, DKIM, Return-Path. We waited for propagation, then sent a single test issue from the sandbox stream to the team's own inboxes. We checked Gmail's show original for SPF: PASS, DKIM: PASS, DMARC: PASS. We confirmed the unsubscribe button at the top of the Gmail message actually worked. It did.

The signup form (1:30 to 2:15)

Mailchimp's embedded form on the marketing site got replaced with a fifteen-line endpoint on the CMS. The form posts an email plus a honeypot field. The endpoint validates the address, inserts a row with status='pending', and sends a double opt-in confirmation through Postmark's transactional stream. Confirmation flips the row to active. Standard double opt-in. No surprises.

The first real send (2:15 to 3:00)

The publisher had a newsletter scheduled for that afternoon anyway. We pointed it at the new worker. Eighty-two batches, three minutes wall time, four bounces, zero complaints. The marketing lead refreshed her inbox and saw the issue arrive at 14:31, on time.

Tear-down (3:00 to 3:20)

We left the Mailchimp account active for one billing cycle as a safety net, then cancelled. Twenty minutes of clicking through the cancellation flow, because Mailchimp does not make leaving easy.

The numbers, one month later

Postmark's broadcast pricing for forty-one thousand sends a week works out to roughly thirty-five euros a month at their published rate. Add the cost of the worker, which runs on the spare slice of a small Hetzner box, and the total monthly bill came to under forty euros. The Mailchimp line item that triggered all of this was eleven hundred. Annualised, that is around thirteen thousand euros recovered, against a one-time bill of half a day of engineering.

Open rates moved from 38.2% to 39.6% in the first month, which we attribute to inbox-friendly headers and a clean separation of broadcast from transactional traffic. We would not bet money on the trend holding. The floor did not drop, which is what we cared about.

What you give up

Honesty section. You give up three things when you make this move, and you should know what they are before you start.

You give up the visual editor. If your marketing team writes their newsletter in the Mailchimp WYSIWYG, you need to give them something else. For this publisher we wired the CMS's existing block editor to MJML, which was already how their developer wrote internal newsletters. If your team has no developer at all, this part is a real piece of work, not a footnote.

You give up the audience-analytics dashboard. Postmark gives you sends, bounces, opens, and clicks. It does not give you best-time-to-send or predictive churn scores. For a publisher whose audience already opens at predictable times, this is no loss. For a B2C marketer who lives in cohort analysis, this matters.

You give up the brand association. Some readers open Mailchimp emails because the footer is familiar. Most do not notice. None of the four bounces on the first send were of the this is not Mailchimp variety. Anecdote, not data.

Takeaway

Mailchimp is one product wearing two hats: a list manager and a sender. If you only need the sender, the list manager is a row in your own database, and the swap is a half-day of work and a 90-line worker.

The smallest move you can make today

If you are paying more than three hundred euros a month for a list under fifty thousand subscribers, open a spreadsheet right now. Write down what your tool does that you actually use. Editor, signup form, list, sender, analytics. Cross off the ones your CMS or your database could already do with thirty lines of code. If the remaining list is the sender, you have a half-day project on your hands.

When we built this swap for the Utrecht publisher, the part we underestimated was the double opt-in flow on the new signup form, not the worker itself. We reused the same HMAC scheme for confirm and unsubscribe, which kept the moving parts down. If your team needs the same kind of process automation shipped without paying a subscription for the privilege, the playbook above is the one we hand to ops on day one.

Key takeaway

Mailchimp is a list manager and a sender wearing one badge. If you only need the sender, swap it for Postmark plus a 90-line worker in half a day.

FAQ

How long did the actual cutover take, end to end?

Three hours and twenty minutes, including DNS propagation, a sandbox test send, rewriting the signup form, and the first live send to forty-one thousand subscribers.

What do you do for open and click analytics without Mailchimp?

Postmark reports opens, clicks, bounces, and complaints out of the box and posts them to a webhook. We write each event to the sends table and query it with SQL.

Will Gmail or Yahoo flag the new sender as spam?

Not if SPF, DKIM, and DMARC align and the List-Unsubscribe headers are set per RFC 8058. Warm the domain by sending one batch a day for a week before the full cutover if your volume is over ten thousand.

Why Postmark instead of Amazon SES?

SES is cheaper at very high volume, but Postmark separates broadcast and transactional streams at the API level and the deliverability defaults are sane on day one. For a one-newsletter publisher the price gap is small.

email automationautomationmigrationintegrationsoperations

Building something?

Start a project