068 —Migration
Transactionele e-mail migreren zonder verzendadres te wijzigen
Je hostingmaatschappij zet over achttien dagen de gedeelde SMTP uit en het verzendadres op drie verouderde sites mag geen byte verschuiven. Het draaiboek.
De briefing van een Nederlands bureau waar we mee werken kwam op zondagavond om 23:41 binnen: hun host had laten weten dat gedeelde SMTP aan het einde van de maand uit zou gaan, en drie van hun oudste klanten leunden nog op PHP's mail()-functie via de lokale sendmail. Orderbevestigingen, wachtwoord-resets, herinneringen voor lidmaatschapsverlengingen, allemaal verstuurd vanaf noreply@theirclient.com via welke route de host op dat uur ook maar koos. Ze hadden achttien dagen om over te stappen op een transactionele provider zonder dat het verzendadres verschoof, zonder in de spam te belanden en zonder dat iemand op kantoor bij de klant er iets van zou merken.
Dit is het draaiboek dat we hebben gevolgd. Elke migratie van transactionele e-mail op een legacy site heeft dezelfde vorm, ongeacht welke provider je uiteindelijk kiest (Postmark, SES, Mailgun, Postal, alles wat SMTP of REST spreekt). Het risico zit zelden in het aansluiten van de nieuwe provider. Het risico zit in de stukjes van de oude site die mail versturen vanaf plekken die niemand ooit heeft gedocumenteerd.
Inventariseer wat er daadwerkelijk mail verstuurt
Voordat je iets aanraakt: zoek elke plek waar de site een Message-ID op de lijn zet. Op een WordPress-site die al jaren draait is dat nooit alleen wp_mail(). Er is altijd:
- Een thema-
functions.phpdie rechtstreeksmail()aanroept om plugins te omzeilen. - Een WooCommerce-extensie waarvan de auteur de
From:-header hard heeft ingebakken. - Een cron-PHP-script in
/home/site/scripts/dat geen enkele plugin-scanner ooit zal zien. - Een Drupal-module die
drupal_mail()gebruikt met een eigen mail system class. - Een oude phpList-installatie in een vergeten submap.
Vind ze met grep, niet uit het hoofd:
grep -rEn "mail\(|wp_mail|PHPMailer|drupal_mail|Swift_Mailer|Symfony\\\\Mailer" \
--include="*.php" /var/www/ 2>/dev/null
Controleer daarna de database op opgeslagen afzenders. WordPress verstopt ze in wp_options, WooCommerce heeft zijn eigen keys, contactformulier-plugins als Contact Form 7 en Gravity Forms houden er één per formulier op na:
SELECT option_name, option_value FROM wp_options
WHERE option_name IN ('admin_email','woocommerce_email_from_address',
'woocommerce_email_from_name','blogname')
OR option_name LIKE '%_email%'
OR option_value LIKE '%@%.%';
Schrijf het resultaat in een audit van twee kolommen waar je later op terugkomt. Pad, functie, huidige From-header, verwachte envelope sender. Zonder die tabel mis je iets, en dan komt er volgende dinsdag een wachtwoord-reset binnen vanaf www-data@srv042.hostingprovider.net.
SPF en DKIM op orde voor de nieuwe verzender
Het verzendadres dat je wilt behouden hoort bij een domein dat jij beheert, maar het IP en de signing keys gaan veranderen. Dit is het stuk dat bureaus het vaakst verkeerd doen. Ze wisselen de SMTP-credentials, en kijken dan een week lang toe hoe de deliverability instort omdat SPF nog steeds alleen de oude host autoriseert. De volgorde doet ertoe. Eerst DNS klaarzetten, verifiëren, en dán pas de applicatie aanpassen. Nooit andersom.
SPF
Voeg het mechanisme van de nieuwe provider toe naast het oude voor de duur van de overgang, en haal het oude er pas uit als je zeker weet dat er niets anders meer via die route verstuurt:
theirclient.com. 300 IN TXT "v=spf1 include:spf.oldhost.net include:spf.postmarkapp.com -all"
SPF kent een harde grens van tien DNS-lookups (RFC 7208 §4.6.4). Op een site die Google Workspace, Microsoft 365 en een CRM al heeft staan, raak je die grens snel. Loop je er tegenaan, vlak SPF dan af met een betrouwbare SPF-tool en controleer opnieuw met dig vanuit minstens twee netwerken.
DKIM
Genereer de key in het dashboard van de nieuwe provider, plak het selector-record in DNS en controleer of de signature in een testverzending opduikt. De naam van de selector doet ertoe, want oude keys resolven vaak nog steeds:
dig +short TXT 20260609._domainkey.theirclient.com
Twee geldige DKIM-keys tegelijk live is geen probleem. Ontvangers accepteren elke signature die valideert. Wat wél een probleem is: live gaan met een key die NXDOMAIN teruggeeft, of een selector die de provider heeft geroteerd sinds jij die screenshot kopieerde.
DMARC opbouwen vóór je de applicatie omzet
Heeft het domein nog geen DMARC-record, begin dan niet bij p=reject. Begin met p=none en een rua-adres dat naar een mailbox wijst die je ook daadwerkelijk leest:
_dmarc.theirclient.com. 300 IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@theirclient.com; fo=1;"
Laat dat minstens zeven dagen draaien en lees de aggregate reports. Je vindt een Mailchimp-account dat de marketingstagiair in 2022 heeft opgezet, een Zendesk die via jouw domein doorforwarded, en waarschijnlijk de monitoring van de host zelf die bounces vanaf jouw domein verstuurt. Los ze één voor één op, ga dan naar p=quarantine, daarna naar p=reject. De DMARC-RFC beschrijft de semantiek; de operationele fout is direct naar enforce springen.
Wissel de applicatie zonder de From-header te wijzigen
De schoonste aanpak op een WordPress-site is om wp_mail() als oppervlak te houden en alleen het transport te vervangen. Doe geen zoek-en-vervang van From-adressen door de codebase heen. Herschrijf geen plugins. Hang in op de filters en laat de nieuwe SMTP-credentials het werk doen:
add_action('phpmailer_init', function ($phpmailer) {
$phpmailer->isSMTP();
$phpmailer->Host = 'smtp.postmarkapp.com';
$phpmailer->Port = 587;
$phpmailer->SMTPAuth = true;
$phpmailer->SMTPSecure = 'tls';
$phpmailer->Username = getenv('POSTMARK_TOKEN');
$phpmailer->Password = getenv('POSTMARK_TOKEN');
});
add_filter('wp_mail_from', function ($from) {
return 'noreply@theirclient.com';
});
add_filter('wp_mail_from_name', function ($name) {
return 'Their Client';
});
De twee filters doen het zware werk. wp_mail_from overschrijft elke plugin die zijn eigen afzender probeert te zetten, en de SMTP-credentials autoriseren precies dat adres bij de provider. Zolang de nieuwe provider het domein heeft geverifieerd en je dezelfde noreply@-mailbox gebruikt, blijft het zichtbare verzendadres staan.
Op Drupal 9 of 10 is het equivalent de Symfony Mailer transport DSN in services.yml. Op een custom PHP-site vervang je mail()-aanroepen door PHPMailer dat naar de nieuwe SMTP-host wijst. In alle drie de gevallen geldt dezelfde regel: het zichtbare adres blijft staan, het transport eronder wordt vervangen.
Het cutover-venster zelf
Plan de cutover op het rustigste slot dat je hebt en doorloop hem in vier observeerbare stappen. Wij kiezen meestal dinsdagochtend om 06:00 lokale tijd. Volgorde is belangrijker dan snelheid.
- DNS staat al live, geverifieerd met
digvanuit drie meetpunten (je eigen machine, een server in een andere regio en een van de publieke lookup-tools). TTL's zijn de dag ervoor verlaagd naar 300. - Deploy de applicatieverandering achter één config switch (
USE_TRANSACTIONAL_SMTP=1in.env). Sloop het oude codepad niet op dag één weg. Misschien heb je het binnen een uur weer nodig. - Stuur een kleine batch echte testberichten vanuit de eigenlijke productiepaden: een wachtwoord-reset, een WooCommerce-orderbevestiging, een contactformulier-inzending. Controleer de headers in Gmail (Origineel weergeven), Outlook (Bron weergeven) en een ProtonMail-inbox. SPF=pass, DKIM=pass, DMARC=pass op alle drie. Faalt er één, dan stop je en draai je terug.
- Houd het dashboard van de provider het eerste uur in de gaten. Bounces, spamklachten, suppressies. Een piek in soft bounces wijst meestal op een verkeerd geconfigureerde envelope. Een piek in hard bounces betekent dat een oude lijst ergens is gekopieerd waar dat niet de bedoeling was.
Houd een rollback klaar. Het hele punt van de feature flag is dat één environment variable je terugbrengt naar het oude transport. Wees niet stoer op vrijdagmiddag.
De stille verzenders nalopen
De audit aan het begin heeft de voor de hand liggende paden gevonden. De cutover laat de rest bovendrijven. Lees de komende twee weken elke ochtend de logs van de provider en elke maandag de DMARC aggregate reports. Doorgaans vind je drie soorten achterblijvers:
- Cron-scripts die nooit via de WordPress-UI zijn ingepland. Die leven in
/etc/cron.d/of in cPanel en doen nog steeds shell-uitvoer naarmail. Wijs ze door naarmsmtpmet de credentials van de provider, of herschrijf ze zodat ze naar de REST API van de provider POSTen. - Server-side notificaties vanuit de host zelf. Back-up-tools, fail2ban, package-upgrades. Die horen vanaf
root@server.theirclient.comte komen, niet vanaf het klantgerichte domein. Zet ze op een subdomein zodat ze de DMARC-rapportage niet meer vervuilen. - Externe integraties die nog steeds via de oude SMTP forwarden. Een Zapier-zap, een CRM, een helpdesk. Elke integratie moet óf authenticeren bij de nieuwe provider, óf overgaan op send-on-behalf met een ander zichtbaar adres.
Dit is het stuk van het draaiboek dat overgeslagen wordt en zes maanden later een Slack-bericht oplevert: "de lidmaatschapsverlengingen zijn opgehouden." Een echte audit betekent logs lezen ná de cutover, niet alleen ervoor.
Hoe de definitieve opzet eruit hoort te zien
Twee weken na de cutover hoort het domein één SPF-record te hebben met alleen de providers die ook echt versturen, één of twee DKIM-selectors die allebei resolven, een DMARC-policy op p=quarantine met pct=100 op weg naar p=reject, en één canoniek From:-adres per verkeersklasse (transactioneel, marketing, systeem) met een gedocumenteerd lijstje welke applicatie welk adres bezit.
Het zichtbare verzendadres is geen byte verschoven. Dat was de opdracht.
Toen we Pier bouwden, struikelden we bij elke migratie over dezelfde auditstap aan het begin: welke bestanden roepen mail() daadwerkelijk aan, wat staat er nog in wp_options, waar zit dat cron-script dat niemand zich herinnert. We hebben het uiteindelijk zo opgelost dat grep en de MySQL editor op één toetsaanslag van de chat zitten, met version history op elke wijziging, zodat een teruggezette hook één klik is in plaats van een paniekerige SSH-sessie.
Heb je deze maand een transactionele migratie voor de boeg, dan is het kleinste nuttige dat je vandaag kunt doen: de grep hierboven over je codebase laten lopen, de SQL-query op je options-tabel afvuren, en die audit in twee kolommen opschrijven. De rest van het draaiboek wordt mechanisch zodra je weet wat er feitelijk verstuurt.
— Vragen —
Moet ik de oude SPF-include na de cutover laten staan?
Laat hem de eerste week staan en check daarna de logs van de provider en je DMARC-rapporten. Heeft de oude host zeven dagen lang niets verstuurd, haal hem dan weg. Anders riskeer je een overflow van SPF-lookups.
Kan ik tegelijk met de provider ook het verzendadres wijzigen?
Doe het niet. De transport-swap verandert al de signing keys en de geautoriseerde IP's. Daar bovenop het verzendadres veranderen is een deliverability-test die je op cutover-dag niet nodig hebt.
Wat als mijn oude host DMARC-alignment al had verbroken?
De migratie maakt het beter, niet slechter. Zet bij de nieuwe provider DKIM en een eigen return-path-subdomein op, monitor zeven dagen op p=none en ga daarna naar enforce.