Reconciler Mail Routing
Leftover MX records from a decommissioned provider
The migration to a new mail provider went fine. The new MX records are in the zone, mail is flowing, everyone moves on. Except nobody ever deleted the old provider's MX records. Now the domain has two sets of MX records pointing at two different mail systems, and mail servers around the internet are free to pick either one. Some of your mail may be quietly landing in a mailbox nobody has opened in months. Here is why that happens and a script that finds the leftovers and removes them.
When a domain moves to a new mail provider, the admin adds the new provider's MX records but forgets to remove the old provider's records. A DNS MX lookup returns every MX record for a domain no matter who owns it, so mail can now go to whichever host wins on the lowest priority number, or split between the old and new provider depending on which sending mail server picks which record. Run dig +short MX example.com, compare every host and priority it returns against the new provider's documented MX set, and delete any record that does not belong. Full code, tests, and a dry run guard are below.
The problem in plain words
An MX record tells the rest of the internet where to deliver mail for your domain. A domain can have more than one MX record, and that is normal, a provider often publishes several for redundancy. The rule that trips people up is that a DNS lookup for MX returns every record in the zone, not just the ones that are still meant to be used. DNS does not know that a record is "old." It only knows what is written down.
So when a domain migrates from an old host to Google Workspace or Microsoft 365, the admin logs in, adds the new provider's MX records, tests that mail works, and calls it done. The old provider's MX records are still sitting in the zone from before. Nothing forces anyone to clean them up, and nothing in the dashboard usually warns that they are still there. Every sending mail server that looks up MX for the domain now sees both sets and has to pick.
Why it happens
- A migration guide walks through adding the new provider's MX records step by step, but never mentions going back to delete the old ones.
- The admin is nervous about "breaking mail" and leaves the old records in place on purpose, planning to remove them later, and later never comes.
- Whoever set up the new provider does not have access to, or does not know about, the account that manages the old provider's mailboxes, so there is no one prompting the cleanup.
- A stale local resolver cache or a high TTL on the old MX records makes the zone look clean during a quick manual check, when the authoritative zone still has the old entries.
Because a domain can legitimately have several MX records for redundancy, an extra record does not look wrong at a glance. Someone scanning the zone editor sees a handful of MX entries and assumes they all belong to the current provider unless they check every hostname against what that provider actually publishes.
DNS has no concept of "the mail provider we use now." It only returns what is written in the zone, and every MX record in that zone is a live, usable answer to any sender that asks. Adding the new provider's records does not retire the old provider's records. Only deleting them does.
The fix, as a flow
Look up the live MX records for the domain and compare each host against the intended provider's documented MX set, matched by hostname suffix, for example anything ending in google.com. for Google Workspace or mail.protection.outlook.com. for Microsoft 365. Anything that does not match that suffix is a leftover from the old provider. Lower the TTL ahead of time, delete the leftover records at the DNS host, then confirm a fresh lookup returns only the intended provider's hosts.
How to fix it
Look at every MX record actually being returned
Query the live MX records directly against a public resolver, not just your local cache, so you see what the rest of the internet sees right now. List every host and priority pair in the answer.
dig +short MX example.com
# bypass a possibly stale local resolver cache
dig @8.8.8.8 MX example.com
dig @1.1.1.1 MX example.com
# a bad result looks like this: the new provider PLUS an old one
# 1 smtp.google.com.
# 10 mx1.oldprovider.net.
# 20 mx2.oldprovider.net.
# confirm you are reading the authoritative answer, not a cache
dig +trace MX example.com
# check the TTL column; a high TTL means stale answers linger longer
dig MX example.com
Compare against the provider's documented MX set
Look up what the current mail provider actually publishes and match every live host against it. Google Workspace's current setup is one record. Microsoft 365 also uses one record, at the domain's own protection hostname. Anything in the live list that is not on this list is a leftover.
# Google Workspace, current single-record setup
example.com. MX 1 smtp.google.com.
# Google Workspace, legacy five-host setup (older accounts)
example.com. MX 1 ASPMX.L.GOOGLE.COM.
example.com. MX 5 ALT1.ASPMX.L.GOOGLE.COM.
example.com. MX 5 ALT2.ASPMX.L.GOOGLE.COM.
example.com. MX 10 ALT3.ASPMX.L.GOOGLE.COM.
example.com. MX 10 ALT4.ASPMX.L.GOOGLE.COM.
# Microsoft 365
example.com. MX 0 example-com.mail.protection.outlook.com.
# a leftover set that should NOT still be there after migrating fully
# to Google Workspace
example.com. MX 10 mx1.oldprovider.net.
example.com. MX 20 mx2.oldprovider.net.
Lower the TTL before you touch anything
At least a day before the cleanup, lower the TTL on the MX records to something short, like 300 seconds. That way, once you do delete the leftover records, resolver caches around the internet clear out quickly instead of holding onto the old answer for hours.
# at least 24 hours ahead of the cleanup, shorten the TTL
example.com. MX 10 mx1.oldprovider.net. TTL 300
example.com. MX 20 mx2.oldprovider.net. TTL 300
# after the cleanup is confirmed working, raise TTL back up on
# the remaining, intended records
example.com. MX 1 smtp.google.com. TTL 3600
Delete the leftover records entirely, do not just deprioritize them
In the DNS host or registrar zone editor, delete every MX record whose target does not match the current provider's documented set. Do not just raise the priority number on the old records "as a fallback." That still leaves them as a valid, usable answer, and some senders will still choose them.
# list every MX record currently in the zone
curl -s -H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=MX" \
| jq '.result[] | {id, priority, content}'
# delete each leftover record by its id, one call per record
curl -X DELETE \
-H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID"
# the final MX set for example.com should be exactly this,
# nothing else, if migrating fully to Google Workspace
# example.com. MX 1 smtp.google.com.
How to check it worked
Re-run the MX lookup from more than one public resolver and confirm only the intended provider's hosts appear. Then send a real test message from an outside mailbox and confirm it arrives in the new provider's mailbox, and check the old provider's mailbox to confirm nothing new is landing there.
# should return only the intended provider, nothing else
dig +short MX example.com
dig @8.8.8.8 MX example.com
dig @1.1.1.1 MX example.com
# for Google Workspace's current setup, the only correct output is:
# 1 smtp.google.com.
# confirm the authoritative nameservers themselves are clean,
# not just a cache
dig +trace MX example.com
A good result is the same clean set of hosts, and nothing else, from every resolver you check. As a last check, send a real test email from an external mailbox and confirm delivery to the new provider, then check the old, decommissioned mailbox and confirm nothing new has arrived there. A tool like MXToolbox can also confirm delivery is consistently hitting only the intended hosts with no bounce-backs or delayed-delivery warnings referencing the old provider.
The full code
Here is the complete checker and repair script in one file for each language. It reads the live MX records for a domain, flags any host that does not match the intended provider's hostname suffix, and, only when you turn dry run off, deletes those leftover records through the Cloudflare API.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect MX records left behind by a decommissioned mail provider
after a migration, and optionally repair the zone via Cloudflare by
deleting those leftover records.
Safe by default. Set DRY_RUN=false to let it write.
Env vars:
DNS_DOMAIN the domain to check, e.g. "example.com"
INTENDED_MX_SUFFIXES comma separated hostname suffixes that
belong to the intended/current provider,
e.g. "google.com." or
"mail.protection.outlook.com."
CLOUDFLARE_API_TOKEN Cloudflare API token (only needed for repair)
CLOUDFLARE_ZONE_ID Cloudflare zone id (only needed for repair)
DRY_RUN default "true"; set to "false" to actually write
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("leftover_mx_records_after_migration")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
INTENDED_MX_SUFFIXES = [
s.strip() for s in os.environ.get("INTENDED_MX_SUFFIXES", "google.com.").split(",") if s.strip()
]
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CF_API = "https://api.cloudflare.com/client/v4"
def _normalize(host):
return host.strip().lower().rstrip(".") + "."
def find_leftover_mx(live_mx, intended_suffixes):
"""Pure decision function. No I/O.
live_mx: list of (priority, exchange_host) tuples currently returned
by a live MX lookup, e.g.
[(1, 'smtp.google.com.'), (10, 'mx1.oldprovider.net.')]
intended_suffixes: list of hostname suffixes that belong to the
intended/current provider, e.g. ['google.com.'] or
['mail.protection.outlook.com.']
Returns the subset of live_mx entries whose exchange host does NOT
end with any of the intended_suffixes (case-insensitive,
trailing-dot normalized), i.e. the leftover records from the
decommissioned provider that should be deleted.
"""
normalized_suffixes = [_normalize(s) for s in intended_suffixes]
leftovers = []
for priority, exchange in live_mx:
host = _normalize(exchange)
if not any(host.endswith(suffix) for suffix in normalized_suffixes):
leftovers.append((priority, exchange))
return leftovers
def fetch_mx_records(domain):
"""Return a list of (priority, exchange) tuples for the domain."""
import dns.resolver
answers = dns.resolver.resolve(domain, "MX")
return [(rdata.preference, str(rdata.exchange)) for rdata in answers]
def list_mx_zone_records(domain):
"""List the id, priority, and content of every MX record via Cloudflare."""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
params = {"type": "MX", "name": domain, "per_page": 100}
r = requests.get(
f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
headers=headers, params=params, timeout=30,
)
r.raise_for_status()
return r.json()["result"]
def delete_record(record_id):
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}"
if DRY_RUN:
log.info("[dry run] would delete record %s", record_id)
return
requests.delete(url, headers=headers, timeout=30).raise_for_status()
log.info("Deleted record %s", record_id)
def run():
live_mx = fetch_mx_records(DNS_DOMAIN)
if not live_mx:
log.info("No MX records found for %s.", DNS_DOMAIN)
return
leftovers = find_leftover_mx(live_mx, INTENDED_MX_SUFFIXES)
if not leftovers:
log.info("All %d MX record(s) for %s match the intended provider.", len(live_mx), DNS_DOMAIN)
return
for priority, exchange in leftovers:
log.warning("Leftover MX record for %s: priority %s exchange %r", DNS_DOMAIN, priority, exchange)
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
log.warning("No Cloudflare credentials set. Not repairing, only reporting.")
return
zone_records = list_mx_zone_records(DNS_DOMAIN)
leftover_hosts = {_normalize(exchange) for _, exchange in leftovers}
for rec in zone_records:
if _normalize(rec["content"]) in leftover_hosts:
delete_record(rec["id"])
log.info("Done. %d leftover record(s) %s.", len(leftovers), "would be removed" if DRY_RUN else "removed")
if __name__ == "__main__":
run()
/**
* Detect MX records left behind by a decommissioned mail provider
* after a migration, and optionally repair the zone via Cloudflare by
* deleting those leftover records.
*
* Safe by default. Set DRY_RUN=false to let it write.
*
* Env vars:
* DNS_DOMAIN the domain to check, e.g. "example.com"
* INTENDED_MX_SUFFIXES comma separated hostname suffixes that
* belong to the intended/current provider,
* e.g. "google.com." or
* "mail.protection.outlook.com."
* CLOUDFLARE_API_TOKEN Cloudflare API token (only needed for repair)
* CLOUDFLARE_ZONE_ID Cloudflare zone id (only needed for repair)
* DRY_RUN default "true"; set to "false" to actually write
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const INTENDED_MX_SUFFIXES = (process.env.INTENDED_MX_SUFFIXES || "google.com.")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";
function normalize(host) {
return host.trim().toLowerCase().replace(/\.*$/, "") + ".";
}
export function findLeftoverMx(liveMx, intendedSuffixes) {
// Pure decision function. No I/O.
//
// liveMx: list of [priority, exchangeHost] tuples currently returned
// by a live MX lookup, e.g.
// [[1, "smtp.google.com."], [10, "mx1.oldprovider.net."]]
// intendedSuffixes: list of hostname suffixes that belong to the
// intended/current provider, e.g. ["google.com."] or
// ["mail.protection.outlook.com."]
//
// Returns the subset of liveMx entries whose exchange host does NOT
// end with any of the intendedSuffixes (case-insensitive,
// trailing-dot normalized), i.e. the leftover records from the
// decommissioned provider that should be deleted.
const normalizedSuffixes = intendedSuffixes.map(normalize);
return liveMx.filter(([, exchange]) => {
const host = normalize(exchange);
return !normalizedSuffixes.some((suffix) => host.endsWith(suffix));
});
}
async function fetchMxRecords(domain) {
const dns = await import("node:dns/promises");
const records = await dns.resolveMx(domain);
return records.map((r) => [r.priority, r.exchange]);
}
async function listMxZoneRecords(domain) {
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
const params = new URLSearchParams({ type: "MX", name: domain, per_page: "100" });
const res = await fetch(
`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?${params.toString()}`,
{ headers },
);
if (!res.ok) throw new Error(`Cloudflare list returned ${res.status}`);
const body = await res.json();
return body.result;
}
async function deleteRecord(recordId) {
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
if (DRY_RUN) {
console.log(`[dry run] would delete record ${recordId}`);
return;
}
const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
method: "DELETE",
headers,
});
if (!res.ok) throw new Error(`Cloudflare delete returned ${res.status}`);
console.log(`Deleted record ${recordId}`);
}
async function run() {
const liveMx = await fetchMxRecords(DNS_DOMAIN);
if (liveMx.length === 0) {
console.log(`No MX records found for ${DNS_DOMAIN}.`);
return;
}
const leftovers = findLeftoverMx(liveMx, INTENDED_MX_SUFFIXES);
if (leftovers.length === 0) {
console.log(`All ${liveMx.length} MX record(s) for ${DNS_DOMAIN} match the intended provider.`);
return;
}
for (const [priority, exchange] of leftovers) {
console.warn(`Leftover MX record for ${DNS_DOMAIN}: priority ${priority} exchange ${JSON.stringify(exchange)}`);
}
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn("No Cloudflare credentials set. Not repairing, only reporting.");
return;
}
const zoneRecords = await listMxZoneRecords(DNS_DOMAIN);
const leftoverHosts = new Set(leftovers.map(([, exchange]) => normalize(exchange)));
for (const rec of zoneRecords) {
if (leftoverHosts.has(normalize(rec.content))) {
await deleteRecord(rec.id);
}
}
console.log(`Done. ${leftovers.length} leftover record(s) ${DRY_RUN ? "would be removed" : "removed"}.`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => {
console.error(err);
process.exit(1);
});
}
Add a test
The decision rule is the part most worth testing, because it decides which records are safe to delete. Because find_leftover_mx is pure, the test needs no network and no DNS provider. It just feeds in plain tuples and checks which ones come back as leftovers.
from leftover_mx_records_after_migration import find_leftover_mx
def test_no_leftovers_when_all_records_match():
live_mx = [(1, "smtp.google.com.")]
assert find_leftover_mx(live_mx, ["google.com."]) == []
def test_flags_old_provider_records():
live_mx = [
(1, "smtp.google.com."),
(10, "mx1.oldprovider.net."),
(20, "mx2.oldprovider.net."),
]
leftovers = find_leftover_mx(live_mx, ["google.com."])
assert leftovers == [(10, "mx1.oldprovider.net."), (20, "mx2.oldprovider.net.")]
def test_matches_are_case_insensitive_and_dot_normalized():
live_mx = [(1, "SMTP.GOOGLE.COM")]
assert find_leftover_mx(live_mx, ["google.com."]) == []
def test_legacy_google_hosts_all_match_suffix():
live_mx = [
(1, "ASPMX.L.GOOGLE.COM."),
(5, "ALT1.ASPMX.L.GOOGLE.COM."),
(10, "ALT3.ASPMX.L.GOOGLE.COM."),
]
assert find_leftover_mx(live_mx, ["google.com."]) == []
def test_microsoft_365_suffix_flags_unrelated_host():
live_mx = [
(0, "example-com.mail.protection.outlook.com."),
(10, "mx1.oldprovider.net."),
]
leftovers = find_leftover_mx(live_mx, ["mail.protection.outlook.com."])
assert leftovers == [(10, "mx1.oldprovider.net.")]
def test_empty_live_mx_returns_empty_list():
assert find_leftover_mx([], ["google.com."]) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findLeftoverMx } from "./leftover-mx-records-after-migration.js";
test("no leftovers when all records match", () => {
const liveMx = [[1, "smtp.google.com."]];
assert.deepEqual(findLeftoverMx(liveMx, ["google.com."]), []);
});
test("flags old provider records", () => {
const liveMx = [
[1, "smtp.google.com."],
[10, "mx1.oldprovider.net."],
[20, "mx2.oldprovider.net."],
];
const leftovers = findLeftoverMx(liveMx, ["google.com."]);
assert.deepEqual(leftovers, [
[10, "mx1.oldprovider.net."],
[20, "mx2.oldprovider.net."],
]);
});
test("matches are case insensitive and dot normalized", () => {
const liveMx = [[1, "SMTP.GOOGLE.COM"]];
assert.deepEqual(findLeftoverMx(liveMx, ["google.com."]), []);
});
test("legacy google hosts all match suffix", () => {
const liveMx = [
[1, "ASPMX.L.GOOGLE.COM."],
[5, "ALT1.ASPMX.L.GOOGLE.COM."],
[10, "ALT3.ASPMX.L.GOOGLE.COM."],
];
assert.deepEqual(findLeftoverMx(liveMx, ["google.com."]), []);
});
test("microsoft 365 suffix flags unrelated host", () => {
const liveMx = [
[0, "example-com.mail.protection.outlook.com."],
[10, "mx1.oldprovider.net."],
];
const leftovers = findLeftoverMx(liveMx, ["mail.protection.outlook.com."]);
assert.deepEqual(leftovers, [[10, "mx1.oldprovider.net."]]);
});
test("empty live mx returns empty list", () => {
assert.deepEqual(findLeftoverMx([], ["google.com."]), []);
});
Case studies
The old hosting company's mail servers, three years later
A company moved its mail to Google Workspace when it switched web hosts. The new MX records for Google went in and mail worked, so the project was marked done. Three years later a security review ran a plain dig +short MX and found the hosting company's original mail servers still listed, priority 10 and 20, right alongside Google's single record at priority 1.
Nobody could say how much mail had quietly gone to the old host over those three years, since its mailbox had not been checked since the migration. Deleting the two leftover records and confirming a clean lookup from multiple resolvers closed the gap.
Deprioritized, not deleted
An admin migrating to Microsoft 365 tried to do the responsible thing and "phase out" the old MX records by raising their priority number to 50, reasoning that a much higher number would make senders ignore them. The old records stayed in the zone for months, technically still valid, still resolvable, still a usable answer.
A handful of external senders, whose own outbound queues had briefly failed to reach the Microsoft 365 host, fell back to the deprioritized old records instead of retrying the primary one, and those messages went to a mailbox nobody was watching. Deleting the old records outright, instead of just deprioritizing them, removed that fallback path entirely.
After the fix, dig +short MX example.com returns only the current provider's documented hosts, for example exactly 1 smtp.google.com. for Google Workspace, from every resolver you check. A real test email arrives at the new provider, and the old, decommissioned mailbox stays empty of anything new. Nothing in the zone still points at a mail system nobody is watching.
FAQ
Why do old MX records cause problems if the new provider's MX records also work?
A domain can have more than one MX record at the same time, and mail servers are free to use any of them. If the old provider's records are still there, some senders may deliver mail to the priority number that wins, or split between both providers, so mail can land in the decommissioned mailbox instead of the new one. Nobody checks that old mailbox, so the message is effectively lost.
Is it enough to just lower the priority of the old MX records instead of deleting them?
No. Raising the priority number on the old records only makes them less preferred, it does not remove them from the answer. Some sending mail servers will still choose to use them, especially if the new provider's host has a temporary issue, so mail can still be delivered to the old, decommissioned provider. The safe fix is to delete the old records entirely, not deprioritize them.
How do I know what the correct MX records should be after migrating to Google Workspace or Microsoft 365?
Check the provider's own documentation rather than guessing. Google Workspace's current setup is a single record, priority 1 pointing to smtp.google.com, though older accounts may still use the five-host legacy set. Microsoft 365 uses a single record pointing to a hostname like example-com.mail.protection.outlook.com. Compare a live dig +short MX lookup against those documented values and remove anything that does not match.
Related field notes
Citations
On the problem:
- Google Workspace Admin Help: Avoid issues when changing MX records. knowledge.workspace.google.com
- DNSimple Help: Manage MX records when changing email providers. support.dnsimple.com
- Practical365: Managing changes to MX records and incoming email traffic. practical365.com
On the solution:
- Google Workspace Help: Set up MX records for Google Workspace. knowledge.workspace.google.com
- Cloudflare API docs: Delete DNS Record. developers.cloudflare.com/api
- Cloudflare API docs: DNS Records, list. developers.cloudflare.com/api
Stuck on a tricky one?
If you have a DNS, domain, or email routing problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your mail delivery?
If this got your inbound mail flowing again or cleared up a confusing bounce, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi