— Article — № 068

068 —Migration

Transactional email migration: keep the from-address

Your host is retiring shared SMTP in eighteen days and the from-address on three legacy sites cannot change. The playbook we ran on a Dutch agency's stack.

Overhead desk scene: SMTP run-sheet, envelope routing diagram, three manila folders, brass key, wax seal on linen.
Hero · staged still№ 068

The brief from a Dutch agency we work with arrived at 23:41 on a Sunday: their host had emailed them that shared SMTP would be retired at month-end, and three of their oldest clients still relied on PHP's mail() function running through local sendmail. Order confirmations, password resets, membership renewal nags, all sent from noreply@theirclient.com via whatever route the host felt like that hour. They had eighteen days to move to a transactional provider without the from-address shifting, without bouncing into spam, and without anyone in the client's office noticing.

This is the playbook we ran. It is the same shape every transactional email migration takes on a legacy site, regardless of which provider you land on (Postmark, SES, Mailgun, Postal, anything that speaks SMTP or REST). The risk is rarely the new provider's wiring. The risk is the bits of the old site that send mail from places nobody documented.

Audit what is actually sending mail

Before you touch anything, find every place the site puts a Message-ID on the wire. On a long-running WordPress site this is never just wp_mail(). There will be:

  • A theme functions.php that calls mail() directly to bypass plugins.
  • A WooCommerce extension whose author hard-coded a From: header.
  • A cron PHP script in /home/site/scripts/ that no plugin scanner will ever see.
  • A Drupal module using drupal_mail() with a custom mail system class.
  • An old phpList install in a forgotten subdirectory.

Find them with grep, not memory:

grep -rEn "mail\(|wp_mail|PHPMailer|drupal_mail|Swift_Mailer|Symfony\\\\Mailer" \
  --include="*.php" /var/www/ 2>/dev/null

Then check the database for stored sender addresses. WordPress hides them in wp_options, WooCommerce stores its own keys, contact form plugins like Contact Form 7 and Gravity Forms keep one per form:

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 '%@%.%';

Write the result into a two-column audit you will come back to. Path, function, current From header, expected envelope sender. Without that table you will miss something, and a password reset will arrive next Tuesday from www-data@srv042.hostingprovider.net.

Align SPF and DKIM for the new sender

The from-address you want to preserve lives at a domain you control, but the IP and signing keys are about to change. This is the part agencies most often get wrong. They flip the SMTP credentials, then watch a week of deliverability collapse because SPF still authorises only the old host. The order matters. Stage the DNS, verify it, then change the application. Never the other way round.

SPF

Add the new provider's mechanism alongside the old one for the duration of the cutover, then remove the old one once you are sure nothing else still sends through it:

theirclient.com.  300  IN  TXT  "v=spf1 include:spf.oldhost.net include:spf.postmarkapp.com -all"

SPF has a hard limit of ten DNS lookups (RFC 7208 §4.6.4). On a site that already lists Google Workspace, Microsoft 365, and a CRM, you can hit it fast. If you do, flatten with a reputable SPF tool and re-check with dig from at least two networks.

DKIM

Generate the key in the new provider's dashboard, paste the selector record into DNS, then verify the signature appears in a test send. The selector name matters because old keys often still resolve:

dig +short TXT 20260609._domainkey.theirclient.com

If you see two valid DKIM keys live at the same time, that is fine. Receivers will accept any signature that validates. What is not fine is shipping with a key that returns NXDOMAIN, or a selector the provider has rotated since you copy-pasted the screenshot.

Stage DMARC before you flip the application

If the domain has no DMARC record yet, do not start at p=reject. Start at p=none with an rua address pointed at a mailbox you actually read:

_dmarc.theirclient.com.  300  IN  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@theirclient.com; fo=1;"

Run that for at least seven days and read the aggregate reports. You will find a Mailchimp account the marketing intern set up in 2022, a Zendesk that forwards through them, and probably the host's own monitoring sending bounces from your domain. Fix each one, then move to p=quarantine, then p=reject. The DMARC RFC spells out the semantics; the operational mistake is jumping straight to enforce.

Switch the application without changing the From header

The cleanest pattern on a WordPress site is to keep wp_mail() as the surface area and replace its transport. Do not search-and-replace From addresses across the codebase. Do not rewrite plugins. Hook the filters and let the new SMTP credentials do the work:

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';
});

The two filters do the load-bearing work. wp_mail_from overrides every plugin that tries to set its own sender, and the SMTP credentials authorise that exact address with the provider. As long as the new provider has verified the domain and you used the same noreply@ mailbox, the visible from-address does not move.

On Drupal 9 or 10 the equivalent is the Symfony Mailer transport DSN configured in services.yml. On a custom PHP site, replace mail() calls with PHPMailer pointed at the new SMTP host. In all three cases the rule is the same: keep the visible address constant, swap the transport beneath it.

The cutover window itself

Plan the cutover for the lowest-traffic slot you have, and run it in four observable steps. We typically pick a Tuesday morning at 06:00 local time. Order matters more than speed.

  1. DNS is already live, verified with dig from three vantage points (your machine, a server in a different region, and one of the public lookup tools). TTLs were dropped to 300 the day before.
  2. Deploy the application change behind a single config switch (USE_TRANSACTIONAL_SMTP=1 in .env). Do not rip out the old code path on day one. You may need it back inside an hour.
  3. Send a small batch of real test messages from the actual production paths: a password reset, a WooCommerce order confirmation, a contact form submission. Check the headers in Gmail (Show Original), Outlook (View Source), and a ProtonMail inbox. SPF=pass, DKIM=pass, DMARC=pass on all three. If any fails, you stop and roll back.
  4. Watch the provider's dashboard for the first hour. Bounces, spam complaints, suppressions. A spike in soft bounces usually means a misconfigured envelope. A spike in hard bounces means an old list got copied somewhere it should not have been.

Keep a rollback ready. The whole point of the feature flag is that one environment variable returns you to the old transport. Do not be brave on a Friday afternoon.

Backfill the silent senders

The audit at the start will have found the obvious paths. The cutover will surface the rest. For the next two weeks, read the provider's logs every morning and the DMARC aggregate reports every Monday. You will typically discover three categories of stragglers:

  • Cron scripts that were never scheduled in the WordPress UI. They live in /etc/cron.d/ or in cPanel and still shell out to mail. Either repoint them to msmtp with the provider's credentials, or rewrite them to POST to the provider's REST API.
  • Server-side notifications from the host itself. Backup tools, fail2ban, package upgrades. These should send from root@server.theirclient.com, not from the customer-facing domain. Move them to a subdomain so they stop polluting DMARC reports.
  • Third-party integrations that still forward through the old SMTP. A Zapier zap, a CRM, a help desk. Each one needs to either authenticate to the new provider or be moved to send-on-behalf with a different visible address.

This is the part of the playbook that gets skipped, then becomes a Slack message six months later: "the membership renewals stopped going out." A real audit means reading logs after the cutover, not just before.

What the finished setup should look like

Two weeks after the cutover, the domain should have one SPF record with only the providers that actually send, one or two DKIM selectors both resolving, a DMARC policy at p=quarantine with pct=100 on track to p=reject, and one canonical From: address per traffic class (transactional, marketing, system) with a documented list of which application owns which.

The visible from-address never moved. That was the brief.

When we built Pier we kept tripping over the same audit step at the start of every migration: which files actually call mail(), what does wp_options still hold, where is the cron script that nobody remembers. The way we ended up handling it was to put grep and the MySQL editor one keystroke away from the chat, with version history on every change so a reverted hook is one click, not a panicked SSH session.

If you have a transactional migration coming up this month, the smallest useful thing you can do today is run the grep above on your codebase and the SQL query against your options table, and write the two-column audit. The rest of the playbook becomes mechanical once you know what is actually sending.

— Questions —

Do I need to keep the old SPF include after cutover?

Keep it for the first week, then check the provider's logs and your DMARC reports. If nothing has sent through the old host for seven days, remove it. Otherwise you risk SPF lookup overflow.

Can I change the from-address at the same time as the provider?

Avoid it. The transport swap already changes signing keys and authorised IPs. A from-address change on top of that is a deliverability test you do not need on cutover day.

What if my old host already broke DMARC alignment?

The migration improves things, it does not worsen them. Set DKIM and a custom return-path subdomain with the new provider, monitor at p=none for seven days, then move to enforce.

How long should TTLs be dropped for?

Drop to 300 seconds at least 48 hours before cutover so caches expire. Raise them back to 3600 or higher once you have run two weeks without changes.

What address should server notifications use?

Send them from a subdomain such as server.yourdomain.com, signed with its own DKIM selector. That keeps fail2ban and backup chatter out of your main domain's DMARC aggregate reports.