Email automation
Van Mailchimp naar Postmark: halve dag werk bij uitgever
De Mailchimp-factuur was elfhonderd euro per maand voor een lijst die in een CSV paste. We ruilden hem in voor Postmark en een worker van 90 regels, vóór de lunch voorbij was.

Het Mailchimp-dashboard stond open op een dinsdagochtend op het kantoor van een Nederlandse vakuitgever in Utrecht. De marketinglead staarde al tien minuten naar hetzelfde scherm. Elfhonderd euro per maand, gefactureerd in dollars, voor een lijst van eenenveertigduizend abonnees en één wekelijkse nieuwsbrief. De groei van de audience was vlak. De merge tags waren dezelfde als in 2018. Niets in dat scherm was veranderd, behalve de prijs.
Ze stuurde de factuur door naar ops met één regel: is dit echt de bodem? Ops stuurde 'm door naar ons met twee: halve dag. Trek 'm er gewoon uit.
Dat deden we. Dit is de playbook.
Waarom Mailchimp eruit gaat, en wanneer niet
Mailchimp verdient zijn geld aan twee kanten van de markt. Aan de kleine kant krijg je een acceptabele drag-and-drop editor en het simpelste signup-formulier ter wereld. Aan de zeer grote kant krijg je audience-segmentatie, predictive scoring en een sales team dat je terugbelt. Het probleem zit in het midden. Als je stack bestaat uit een CMS, een CSV en een developer die vijftig regels JavaScript kan schrijven, betaal je de enterprise-prijs voor het kleine-zaak-product.
De uitgever in Utrecht zat precies in dat midden. Eén lijst. Eén wekelijkse aflevering. Templates die in drie jaar niet waren veranderd. Open rates die niet afhingen van de slimmigheid van Mailchimp, want het publiek had zich al aangemeld en was al loyaal.
De vraag was dus niet is Mailchimp slecht. De vraag was past Mailchimp bij deze klus. Het antwoord was nee.
De splitsing die de swap eenvoudig maakt
De meeste "ik wil van Mailchimp af"-posts lopen tegen dezelfde muur op. Ze proberen één product te vervangen door één product. Dat is de verkeerde insteek. Je vervangt Mailchimp door twee onderdelen:
- Een sender. Het ding dat mail op de lijn zet, DKIM-ondertekening regelt en bounces en complaints rapporteert. Postmark, Resend, Amazon SES zijn allemaal prima. Wij kozen Postmark omdat de batch email API menselijk leesbaar is en de scheiding tussen transactional en broadcast streams op API-niveau wordt afgedwongen. Dat houdt je uit de gedeelde-reputatie-val.
- Een lijst. Een rij in je eigen database met een status-kolom. Meer niet. Active, unsubscribed, bounced, complained. Vier states. Eén tabel.
Zodra je die splitsing ziet, valt de waarde van een Mailchimp-vormig product weg. De editor? Je CMS heeft er al een. Het signup-formulier? Vijftien regels op de marketingsite. De segmentatie? Daar heb je SQL voor. De deliverability-magie? Postmark en SES hebben het afgelopen decennium ervoor gezorgd dat ondertekende, geauthenticeerde mail met een schone suppression list de inbox haalt.
De architectuur, op één pagina
Dit is wat we op een notitieblok tekenden voordat we ook maar één regel code schreven.
- Het CMS sloeg afleveringen al op als content type
newsletter. We voegden twee velden toe:subjectenpreview_text. De body werd door een bestaande MJML-pipeline gerenderd naar HTML en plain text. - Abonnees stonden in een Postgres-tabel met
email,first_name,list,statusencreated_at. De unsubscribe-token was een HMAC van het adres, dus we hoefden er nooit een op te slaan. - Een kleine worker, draaiend op de Hetzner-VM die de uitgever al bezat, deed drie dingen. Hij pakte een aflevering op zodra
send_atbereikt was, fanned die aflevering uit naar Postmark in batches van vijfhonderd, en schreef per ontvanger één rij naar eensends-tabel. - Een Postmark-webhook postte bounces, complaints en one-click unsubscribes terug naar een minuscuul HTTP endpoint dat de
statusop de abonnee-rij bijwerkte.
Dat is het hele plaatje. Geen queues. Geen retries buiten wat de SDK zelf doet. Geen campaign-object, want een rij in de issues-tabel is een campagne.
De worker, in negentig regels
Dit is het bestand. Node 20, de officiële Postmark SDK en de Supabase-client, omdat de stack van de uitgever dat toch al gebruikte. Ruil de database-client voor wat jij gebruikt; de vorm blijft hetzelfde.
// 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));
Drie dingen zijn het noemen waard, want dit zijn de stukken die je de eerste keer fout gaat doen.
De batch endpoint is je vriend. Postmark accepteert tot vijfhonderd berichten per call. Eén round-trip stuurt naar vijfhonderd ontvangers. Eenenveertigduizend abonnees fannen uit in tweeëntachtig requests. Op een residentiële verbinding in Utrecht was de hele zending in net iets minder dan drie minuten klaar.
Per ontvanger een eigen body, geen BCC. Elk bericht gaat To één adres. Geen BCC. Geen undisclosed-recipients-truc. Dát laat je personaliseren, dát laat de inbox provider schone envelope-adressen zien, en dát houdt je domeinreputatie intact.
De unsubscribe header is niet optioneel. Gmail en Yahoo zijn vanaf februari 2024 begonnen met het afdwingen van RFC 8058 one-click unsubscribe voor bulk-zenders. Als je headers List-Unsubscribe en List-Unsubscribe-Post missen, gaat je mail naar spam. De worker schrijft beide bij elke send.
Stuur je meer dan vijfduizend berichten per dag naar Gmail- of Yahoo-adressen, dan moet je DMARC-policy op het domein minimaal op p=none staan met een rapportageadres, moeten SPF en DKIM aligned zijn en moet one-click unsubscribe werken in één POST. Mis er één en je bulk-stream gaat regelrecht naar de junk. Test vóór de cutover, niet erna.
Authenticatie, één keer instellen en vergeten
Elk domein dat we naar Postmark verhuizen krijgt dezelfde drie DNS-wijzigingen, in dezelfde volgorde, voor er ook maar één mail uitgaat.
- Een SPF-record (of een update op het bestaande) dat
spf.mtasv.netincludeert. De bestaande Google Workspace-include van de uitgever blijft staan; Postmark gaat ernaast staan. - Twee DKIM-CNAMEs die Postmark per server genereert. Gebruik de 2048-bit keys, niet de legacy 1024.
- Een Return-Path CNAME zodat bounce-verwerking gebeurt op een subdomein van de uitgever zelf, en niet op een Postmark-branded domein.
DMARC stond op het domein van de uitgever al op p=quarantine. Daar bleven we vanaf. Begin je vanaf nul, start dan op p=none met rua= richting een monitoring-inbox, draai twee weken en schroef daarna op. Ga niet op dag één meteen naar p=reject.
De cutover, stap voor stap
De daadwerkelijke swap kostte drie uur en twintig minuten. Dit is waar die minuten in gingen zitten.
Exporteren, opschonen, importeren (0:00 tot 0:45)
We exporteerden de Mailchimp-audience als CSV. Eenenveertigduizend rijen. Een eenmalig script zette elk adres lowercase, gooide rijen weg waar Mailchimp een andere status dan subscribed had, en normaliseerde de first-name kolom (zo'n duizend rijen stonden als "Maaike " met een trailende spatie). Het opgeschoonde bestand ging met status='active' in één COPY-statement de subscribers-tabel in.
DNS, daarna een sandbox-send (0:45 tot 1:30)
SPF, DKIM, Return-Path. We wachtten op propagatie en stuurden toen één test-aflevering vanuit de sandbox-stream naar de inboxes van het team. We checkten Gmail's toon origineel op SPF: PASS, DKIM: PASS, DMARC: PASS. We controleerden of de unsubscribe-knop bovenin het Gmail-bericht ook echt werkte. Dat deed hij.
Het signup-formulier (1:30 tot 2:15)
Het embedded Mailchimp-formulier op de marketingsite werd vervangen door een endpoint van vijftien regels op het CMS. Het formulier post een email plus een honeypot-veld. Het endpoint valideert het adres, voegt een rij toe met status='pending' en stuurt een double opt-in-bevestiging via de transactional stream van Postmark. Bevestiging zet de rij om naar active. Standaard double opt-in. Geen verrassingen.
De eerste echte send (2:15 tot 3:00)
De uitgever had die middag toch al een nieuwsbrief gepland staan. We zetten 'm op de nieuwe worker. Tweeëntachtig batches, drie minuten wall time, vier bounces, nul complaints. De marketinglead refreshte haar inbox en zag de aflevering binnenkomen om 14:31, op tijd.
Opruimen (3:00 tot 3:20)
We lieten het Mailchimp-account voor één facturatie-cyclus actief staan als vangnet, en zegden daarna op. Twintig minuten doorklikken op de cancellation flow, want Mailchimp maakt vertrekken niet makkelijk.
De cijfers, één maand later
De broadcast-prijzen van Postmark voor eenenveertigduizend sends per week komen volgens hun publieke tarief uit op ongeveer vijfendertig euro per maand. Tel daar de kosten van de worker bij op, die draait op een spare stuk van een kleine Hetzner-box, en de totale maandfactuur kwam onder de veertig euro uit. De Mailchimp-regel die dit hele verhaal in gang zette, was elfhonderd. Op jaarbasis is dat ongeveer dertienduizend euro die je terugkrijgt, tegenover een eenmalige investering van een halve dag engineering.
Open rates schoven in de eerste maand van 38,2% naar 39,6%, wat we toeschrijven aan inbox-vriendelijke headers en een schone scheiding tussen broadcast en transactional traffic. We zouden er geen geld op zetten dat die trend doorzet. De bodem zakte niet, en dat was waar het ons om ging.
Wat je opgeeft
Eerlijkheid-sectie. Je geeft drie dingen op als je deze stap zet, en het is goed om die te kennen voordat je begint.
Je geeft de visuele editor op. Schrijft je marketingteam de nieuwsbrief in de Mailchimp WYSIWYG, dan moet je ze iets anders geven. Voor deze uitgever koppelden we de bestaande block editor van het CMS aan MJML, wat al de manier was waarop hun developer interne nieuwsbrieven schreef. Heb je helemaal geen developer in het team, dan is dit stuk echt werk, geen voetnoot.
Je geeft het audience-analytics dashboard op. Postmark geeft je sends, bounces, opens en clicks. Het geeft je geen best-time-to-send of predictive churn scores. Voor een uitgever met een publiek dat al op voorspelbare tijden opent, is dat geen verlies. Voor een B2C-marketeer die in cohort-analyse leeft, telt het wel.
Je geeft de merkassociatie op. Sommige lezers openen Mailchimp-mails omdat de footer vertrouwd voelt. De meeste merken het niet op. Geen van de vier bounces bij de eerste send was van het dit is geen Mailchimp-type. Anekdote, geen data.
Mailchimp is één product met twee petten op: een list manager en een sender. Heb je alleen de sender nodig, dan is de list manager een rij in je eigen database, en is de swap een halve dag werk plus een worker van 90 regels.
De kleinste stap die je vandaag kunt zetten
Betaal je meer dan driehonderd euro per maand voor een lijst onder de vijftigduizend abonnees, open dan nu een spreadsheet. Schrijf op wat je tool doet dat je daadwerkelijk gebruikt. Editor, signup-formulier, lijst, sender, analytics. Streep door wat je CMS of je database al zou kunnen doen met dertig regels code. Blijft de sender over, dan heb je een halve dag werk voor je liggen.
Toen we deze swap voor de uitgever in Utrecht bouwden, was het stuk dat we onderschatten de double opt-in-flow op het nieuwe signup-formulier, niet de worker zelf. We hergebruikten hetzelfde HMAC-schema voor bevestigen en uitschrijven, wat het aantal bewegende delen omlaag hield. Heeft jouw team dezelfde procesautomatisering nodig zonder dat je daar maandelijks een abonnement voor afdraagt, dan is dit de playbook die we ops op dag één in handen geven.
Kern
Mailchimp is een list manager en een sender onder één badge. Heb je alleen de sender nodig, ruil hem dan in voor Postmark plus een worker van 90 regels in een halve dag.
FAQ
Hoe lang duurde de daadwerkelijke cutover, van begin tot eind?
Drie uur en twintig minuten, inclusief DNS-propagatie, een sandbox-test send, het herschrijven van het signup-formulier en de eerste live send naar eenenveertigduizend abonnees.
Wat doe je voor open- en click-analytics zonder Mailchimp?
Postmark rapporteert opens, clicks, bounces en complaints standaard en post ze naar een webhook. We schrijven elk event naar de sends-tabel en bevragen die met SQL.
Gaat Gmail of Yahoo de nieuwe sender als spam markeren?
Niet als SPF, DKIM en DMARC aligned zijn en de List-Unsubscribe headers ingesteld staan volgens RFC 8058. Ligt je volume boven de tienduizend, warm het domein dan op door een week lang één batch per dag te sturen voor de volledige cutover.
Waarom Postmark en niet Amazon SES?
SES is goedkoper bij heel grote volumes, maar Postmark scheidt broadcast en transactional streams op API-niveau en de deliverability-defaults zijn op dag één al verstandig. Voor een uitgever met één nieuwsbrief is het prijsverschil klein.