Repair Mail Routing
MX record points to an unreachable host
The domain's MX record still lists a mail host, and DNS still hands back an answer for it, so everything looks configured. But that host is gone. Maybe the mail provider was migrated away from, or the old server was shut down, and nobody updated DNS to match. Mail keeps queuing quietly for a day or two, then bounces, and nobody notices until the bounce shows up. Here is why that happens and a script that finds every dead MX host and repoints the record at a live one.
The domain's MX record still names a mail host that no longer runs an SMTP listener, usually because it was decommissioned, migrated away from, or a hosting plan lapsed without DNS being updated. DNS resolution of the hostname still works, so nothing looks broken until a sender actually tries to connect to it on port 25 and gets refused or times out. Per RFC 5321, senders only deliver to the addresses in the MX record, so a dead target causes mail to queue for 24 to 48 hours and then bounce, silently, until someone checks. The fix is to repoint the MX record at a mail host that actually answers on port 25, or promote a working secondary MX if one already exists. Full code, tests, and a dry run guard are below.
The problem in plain words
An MX record has two jobs baked into one line: a priority number, and a hostname to try mail delivery at. When you look the record up with a tool like dig, you get back a clean answer, and that hostname even resolves to an IP address. Everything about the DNS layer says "this is fine."
The catch is that DNS only answers the question "what hostname and address should I try?" It never asks or answers "is anything actually listening there?" If the server behind that address was retired, replaced, or the account that ran it was canceled, the DNS records can be left completely untouched while the machine itself is gone. The domain looks correctly configured on paper. It is only wrong in practice, and only a real delivery attempt exposes that.
Why it happens
- A mail provider migration happens, for example moving from a self hosted mail server to Google Workspace or Microsoft 365, and the old MX record is left in place alongside or instead of the new one.
- The server that used to run the mail software is decommissioned or repurposed for something else, but nobody remembers DNS still points there.
- A hosting account or VPS plan is canceled for non payment or during a cleanup, and the mail host silently disappears along with the DNS record that references it.
- A secondary or backup MX record was set up years ago pointing at a host that has since been retired, and it is only found when the primary also fails.
In every case, the DNS record itself is syntactically fine. The hostname resolves. There is no NXDOMAIN, no malformed record, nothing a quick glance at a DNS dashboard would flag. The break is entirely at the network and application layer, which is why it hides so well.
An MX record answering does not mean mail can be delivered. It only means DNS knows a hostname and that hostname has an address. Whether that address actually runs a mail server is a completely separate question, one DNS never answers. The only way to know is to connect to port 25 and see what happens, the same way a real sending mail server would.
The fix, as a flow
Resolve the MX records for the domain in priority order. For each target, resolve its address and then open a real connection to port 25. A healthy host answers right away with a banner starting 220. A dead host refuses the connection, times out, or the hostname does not resolve at all. If every MX host fails that check, or if a healthy lower priority host already exists, repoint the record: either promote a working secondary, update the dead host's address, or replace the whole set with a mail provider's documented hosts.
How to fix it
Resolve the MX records for the domain
List the MX records in priority order, lowest preference number first. Empty output means there is no MX record at all, which is a different problem. Anything returned here should look like a real hostname with a priority number in front of it.
dig +short MX example.com
# expect lines like:
# 10 mail1.example.com.
# 20 mail2.example.com.
# cross-check from a different resolver, rule out local caching
dig @1.1.1.1 MX example.com
Resolve each MX host to an address
For every MX target returned above, in priority order, check that it still has an A or AAAA record. NXDOMAIN or no answer here means the hostname itself is dangling, which is one of the ways this problem shows up.
# repeat for every MX host, in priority order
dig +short A mail1.example.com
dig +short AAAA mail1.example.com
# bad sign: NXDOMAIN or empty output, the hostname is dangling
Probe SMTP connectivity on port 25
Open a real connection to the resolved address on port 25. A healthy host answers immediately with a banner like 220 mail1.example.com ESMTP ready. A dead host gives connection refused, connection timed out, or the socket just hangs with no banner at all.
nc -vz -w 5 mail1.example.com 25
# or, to see the actual banner
openssl s_client -connect mail1.example.com:25 -starttls smtp
# healthy: 220 mail1.example.com ESMTP ready
# dead: Connection refused / Connection timed out / no banner at all
# repeat steps 2-3 for every MX record, lowest priority number first
# if ALL of them fail, mail delivery is fully broken, not just degraded
Repoint the MX record at a live host
If a lower priority record (a secondary) is already healthy, delete the dead one and let the healthy one take over. If nothing is healthy, either fix the dead host's address, or replace the MX set with a mail provider's documented hosts, such as Google Workspace's single modern host.
# BEFORE (bug): mail1 is dead, mail2 is a healthy secondary
example.com. MX 10 mail1.example.com.
example.com. MX 20 mail2.example.com.
# AFTER (option a): delete the dead record, promote the healthy one
example.com. MX 10 mail2.example.com.
# AFTER (option b): if migrating providers, e.g. Google Workspace
example.com. MX 1 smtp.google.com.
Publish the correction through the Cloudflare API (or dashboard)
In the Cloudflare dashboard this is DNS, Records: edit the MX record whose target is dead and change its content to the live hostname, keeping the priority number the same unless you are intentionally re-ranking hosts. The same edit works through the API with a PATCH request.
# find the existing MX record for the domain
curl -s -H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=MX&name=example.com" \
| jq '.result[] | {id, priority, content}'
# repoint the dead record at a live host, same priority
curl -X PATCH \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-d '{"type":"MX","name":"example.com","content":"mail2.example.com","priority":10,"ttl":3600}'
How to check it worked
Re-run the MX lookup and confirm it no longer references the dead host. Then confirm the new hostname resolves and actually answers on port 25 with a proper SMTP banner. Finally send a real test email and confirm it arrives with no bounce, and re-check from public resolvers to confirm the change propagated.
# should show only live hostnames now
dig +short MX example.com
# should return a routable IP address
dig +short A mail2.example.com
# should connect within a couple seconds and print a 220 banner
nc -vz -w 5 mail2.example.com 25
openssl s_client -connect mail2.example.com:25 -starttls smtp
# confirm propagation to public resolvers, respecting the record's TTL
dig @8.8.8.8 MX example.com
dig @1.1.1.1 MX example.com
A good result is dig +short MX example.com returning only hostnames you have confirmed are alive, and each of those hostnames answering on port 25 with a 220 ... ESMTP banner within a couple of seconds. As a last check, send a real test email from an external account, such as Gmail, to an address at the domain and confirm it arrives without a bounce or delay warning. Tools like mail-tester.com or MXToolbox's MX Lookup and SMTP Diagnostics can automate that last check.
The full code
Here is the complete checker and repair script in one file for each language. It resolves the MX records for a domain, opens a raw socket to port 25 on each host to classify it as reachable, refused, timed out, or non resolving, and, only when you turn dry run off, repoints the MX record through the Cloudflare API.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect an MX record that points at a host with no working SMTP
listener on port 25 (dangling hostname, refused connection, or
timeout), and optionally repair the zone via Cloudflare by repointing
the record at a known-good mail host.
Safe by default. Set DRY_RUN=false to let it write.
Env vars:
DNS_DOMAIN the domain to check, e.g. "example.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("mx_points_to_dead_host")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
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"
SMTP_PORT = 25
SOCKET_TIMEOUT = 5
def classify_mx_health(mx_records, resolved_ips, port25_results):
"""Pure decision function. No I/O.
mx_records: list of (priority, hostname) tuples.
resolved_ips: dict mapping hostname -> list of ip strings; an empty
list means NXDOMAIN or no A/AAAA record (a dangling hostname).
port25_results: dict mapping hostname -> one of "connected",
"refused", "timeout", or "no_dns".
Returns a dict with one entry per hostname, "healthy", "dangling",
or "unreachable", plus an "all_down" boolean that is True only when
every hostname's status is not "connected".
"""
status = {}
for _, hostname in mx_records:
ips = resolved_ips.get(hostname, [])
result = port25_results.get(hostname, "no_dns")
if not ips or result == "no_dns":
status[hostname] = "dangling"
elif result == "connected":
status[hostname] = "healthy"
else:
status[hostname] = "unreachable"
all_down = all(v != "healthy" for v in status.values()) if status else True
return {**status, "all_down": all_down}
def fetch_mx_records(domain):
"""Return a list of (preference, exchange) tuples for the domain."""
import dns.resolver
answers = dns.resolver.resolve(domain, "MX")
records = [(rdata.preference, str(rdata.exchange).rstrip(".")) for rdata in answers]
records.sort(key=lambda item: item[0])
return records
def resolve_host(hostname):
"""Return a list of IP address strings for hostname, or [] if none."""
import dns.resolver
import dns.exception
ips = []
for rtype in ("A", "AAAA"):
try:
answers = dns.resolver.resolve(hostname, rtype)
ips.extend(str(rdata) for rdata in answers)
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.exception.DNSException):
continue
return ips
def probe_port25(hostname, ip):
"""Open a raw socket to ip:25 and read the SMTP banner.
Returns "connected" if a banner starting with "220" is read,
"refused" on connection refused, "timeout" on a timeout, and
"no_dns" if ip is falsy (nothing to connect to).
"""
import socket
if not ip:
return "no_dns"
try:
with socket.create_connection((ip, SMTP_PORT), timeout=SOCKET_TIMEOUT) as sock:
sock.settimeout(SOCKET_TIMEOUT)
banner = sock.recv(256)
return "connected" if banner.startswith(b"220") else "refused"
except ConnectionRefusedError:
return "refused"
except (socket.timeout, TimeoutError):
return "timeout"
except OSError:
return "refused"
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 repoint_mx_record(record_id, domain, new_content, priority):
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
body = {"type": "MX", "name": domain, "content": new_content, "priority": priority, "ttl": 3600}
if DRY_RUN:
log.info("[dry run] would repoint record %s to %s (priority %s)", record_id, new_content, priority)
return
r = requests.patch(
f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}",
headers=headers, json=body, timeout=30,
)
r.raise_for_status()
log.info("Repointed record %s to %s (priority %s)", record_id, new_content, priority)
def run(known_good_host=None):
records = fetch_mx_records(DNS_DOMAIN)
if not records:
log.info("No MX records found for %s.", DNS_DOMAIN)
return
resolved_ips = {}
port25_results = {}
for _, hostname in records:
ips = resolve_host(hostname)
resolved_ips[hostname] = ips
port25_results[hostname] = probe_port25(hostname, ips[0] if ips else None)
health = classify_mx_health(records, resolved_ips, port25_results)
for _, hostname in records:
log.info("MX host %s: %s", hostname, health[hostname])
if not health["all_down"]:
log.info("At least one MX host for %s is healthy. No repair needed.", DNS_DOMAIN)
return
log.warning("All MX hosts for %s are down. Mail delivery is fully broken.", DNS_DOMAIN)
if not known_good_host:
log.warning("No known-good replacement host provided. Not repairing, only reporting.")
return
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)
for rec in zone_records:
repoint_mx_record(rec["id"], DNS_DOMAIN, known_good_host, rec["priority"])
log.info("Done.")
if __name__ == "__main__":
run(known_good_host=os.environ.get("KNOWN_GOOD_MX_HOST", ""))
/**
* Detect an MX record that points at a host with no working SMTP
* listener on port 25 (dangling hostname, refused connection, or
* timeout), and optionally repair the zone via Cloudflare by
* repointing the record at a known-good mail host.
*
* Safe by default. Set DRY_RUN=false to let it write.
*
* Env vars:
* DNS_DOMAIN the domain to check, e.g. "example.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
* KNOWN_GOOD_MX_HOST hostname to repoint to when all MX hosts are down
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
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 KNOWN_GOOD_MX_HOST = process.env.KNOWN_GOOD_MX_HOST || "";
const CF_API = "https://api.cloudflare.com/client/v4";
const SMTP_PORT = 25;
const SOCKET_TIMEOUT_MS = 5000;
export function classifyMxHealth(mxRecords, resolvedIps, port25Results) {
// Pure decision function. No I/O.
//
// mxRecords: array of [priority, hostname] pairs.
// resolvedIps: object mapping hostname -> array of ip strings; an
// empty array means NXDOMAIN or no A/AAAA record (dangling host).
// port25Results: object mapping hostname -> one of "connected",
// "refused", "timeout", or "no_dns".
//
// Returns an object with one entry per hostname, "healthy",
// "dangling", or "unreachable", plus an "all_down" boolean that is
// true only when every hostname's status is not "connected".
const status = {};
for (const [, hostname] of mxRecords) {
const ips = resolvedIps[hostname] || [];
const result = port25Results[hostname] || "no_dns";
if (ips.length === 0 || result === "no_dns") {
status[hostname] = "dangling";
} else if (result === "connected") {
status[hostname] = "healthy";
} else {
status[hostname] = "unreachable";
}
}
const hostnames = Object.keys(status);
const allDown = hostnames.length === 0 || hostnames.every((h) => status[h] !== "healthy");
return { ...status, all_down: allDown };
}
async function fetchMxRecords(domain) {
const dns = await import("node:dns/promises");
const records = await dns.resolveMx(domain);
records.sort((a, b) => a.priority - b.priority);
return records.map((r) => [r.priority, r.exchange.replace(/\.$/, "")]);
}
async function resolveHost(hostname) {
const dns = await import("node:dns/promises");
const ips = [];
for (const method of ["resolve4", "resolve6"]) {
try {
const addrs = await dns[method](hostname);
ips.push(...addrs);
} catch {
continue;
}
}
return ips;
}
function probePort25(ip) {
return new Promise(async (resolve) => {
if (!ip) {
resolve("no_dns");
return;
}
const net = await import("node:net");
const socket = new net.Socket();
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(result);
};
socket.setTimeout(SOCKET_TIMEOUT_MS);
socket.once("timeout", () => finish("timeout"));
socket.once("error", (err) => finish(err.code === "ECONNREFUSED" ? "refused" : "refused"));
socket.once("data", (chunk) => finish(chunk.toString("utf8").startsWith("220") ? "connected" : "refused"));
socket.connect(SMTP_PORT, ip);
});
}
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 repointMxRecord(recordId, domain, newContent, priority) {
const headers = {
Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
"Content-Type": "application/json",
};
if (DRY_RUN) {
console.log(`[dry run] would repoint record ${recordId} to ${newContent} (priority ${priority})`);
return;
}
const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
method: "PATCH",
headers,
body: JSON.stringify({ type: "MX", name: domain, content: newContent, priority, ttl: 3600 }),
});
if (!res.ok) throw new Error(`Cloudflare patch returned ${res.status}`);
console.log(`Repointed record ${recordId} to ${newContent} (priority ${priority})`);
}
async function run() {
const records = await fetchMxRecords(DNS_DOMAIN);
if (records.length === 0) {
console.log(`No MX records found for ${DNS_DOMAIN}.`);
return;
}
const resolvedIps = {};
const port25Results = {};
for (const [, hostname] of records) {
const ips = await resolveHost(hostname);
resolvedIps[hostname] = ips;
port25Results[hostname] = await probePort25(ips[0]);
}
const health = classifyMxHealth(records, resolvedIps, port25Results);
for (const [, hostname] of records) {
console.log(`MX host ${hostname}: ${health[hostname]}`);
}
if (!health.all_down) {
console.log(`At least one MX host for ${DNS_DOMAIN} is healthy. No repair needed.`);
return;
}
console.warn(`All MX hosts for ${DNS_DOMAIN} are down. Mail delivery is fully broken.`);
if (!KNOWN_GOOD_MX_HOST) {
console.warn("No known-good replacement host provided. Not repairing, only reporting.");
return;
}
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn("No Cloudflare credentials set. Not repairing, only reporting.");
return;
}
const zoneRecords = await listMxZoneRecords(DNS_DOMAIN);
for (const rec of zoneRecords) {
await repointMxRecord(rec.id, DNS_DOMAIN, KNOWN_GOOD_MX_HOST, rec.priority);
}
console.log("Done.");
}
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 classification rule is the part most worth testing, because it decides whether a host is trusted as healthy or flagged for repair. Because classify_mx_health is pure, the test needs no network, no DNS lookups, and no socket connections. It just feeds in plain fixture data and checks the verdict.
from mx_points_to_dead_host import classify_mx_health
def test_single_healthy_host_is_not_all_down():
records = [(10, "mail1.example.com")]
ips = {"mail1.example.com": ["203.0.113.10"]}
ports = {"mail1.example.com": "connected"}
result = classify_mx_health(records, ips, ports)
assert result["mail1.example.com"] == "healthy"
assert result["all_down"] is False
def test_refused_host_is_unreachable():
records = [(10, "mail1.example.com")]
ips = {"mail1.example.com": ["203.0.113.10"]}
ports = {"mail1.example.com": "refused"}
result = classify_mx_health(records, ips, ports)
assert result["mail1.example.com"] == "unreachable"
assert result["all_down"] is True
def test_timeout_host_is_unreachable():
records = [(10, "mail1.example.com")]
ips = {"mail1.example.com": ["203.0.113.10"]}
ports = {"mail1.example.com": "timeout"}
result = classify_mx_health(records, ips, ports)
assert result["mail1.example.com"] == "unreachable"
def test_no_a_record_is_dangling():
records = [(10, "mail1.example.com")]
ips = {"mail1.example.com": []}
ports = {"mail1.example.com": "no_dns"}
result = classify_mx_health(records, ips, ports)
assert result["mail1.example.com"] == "dangling"
assert result["all_down"] is True
def test_one_dead_one_healthy_is_not_all_down():
records = [(10, "mail1.example.com"), (20, "mail2.example.com")]
ips = {"mail1.example.com": [], "mail2.example.com": ["203.0.113.20"]}
ports = {"mail1.example.com": "no_dns", "mail2.example.com": "connected"}
result = classify_mx_health(records, ips, ports)
assert result["mail1.example.com"] == "dangling"
assert result["mail2.example.com"] == "healthy"
assert result["all_down"] is False
def test_both_dead_is_all_down():
records = [(10, "mail1.example.com"), (20, "mail2.example.com")]
ips = {"mail1.example.com": ["203.0.113.10"], "mail2.example.com": []}
ports = {"mail1.example.com": "refused", "mail2.example.com": "no_dns"}
result = classify_mx_health(records, ips, ports)
assert result["all_down"] is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyMxHealth } from "./mx-points-to-dead-host.js";
test("single healthy host is not all down", () => {
const records = [[10, "mail1.example.com"]];
const ips = { "mail1.example.com": ["203.0.113.10"] };
const ports = { "mail1.example.com": "connected" };
const result = classifyMxHealth(records, ips, ports);
assert.equal(result["mail1.example.com"], "healthy");
assert.equal(result.all_down, false);
});
test("refused host is unreachable", () => {
const records = [[10, "mail1.example.com"]];
const ips = { "mail1.example.com": ["203.0.113.10"] };
const ports = { "mail1.example.com": "refused" };
const result = classifyMxHealth(records, ips, ports);
assert.equal(result["mail1.example.com"], "unreachable");
assert.equal(result.all_down, true);
});
test("timeout host is unreachable", () => {
const records = [[10, "mail1.example.com"]];
const ips = { "mail1.example.com": ["203.0.113.10"] };
const ports = { "mail1.example.com": "timeout" };
const result = classifyMxHealth(records, ips, ports);
assert.equal(result["mail1.example.com"], "unreachable");
});
test("no A record is dangling", () => {
const records = [[10, "mail1.example.com"]];
const ips = { "mail1.example.com": [] };
const ports = { "mail1.example.com": "no_dns" };
const result = classifyMxHealth(records, ips, ports);
assert.equal(result["mail1.example.com"], "dangling");
assert.equal(result.all_down, true);
});
test("one dead one healthy is not all down", () => {
const records = [[10, "mail1.example.com"], [20, "mail2.example.com"]];
const ips = { "mail1.example.com": [], "mail2.example.com": ["203.0.113.20"] };
const ports = { "mail1.example.com": "no_dns", "mail2.example.com": "connected" };
const result = classifyMxHealth(records, ips, ports);
assert.equal(result["mail1.example.com"], "dangling");
assert.equal(result["mail2.example.com"], "healthy");
assert.equal(result.all_down, false);
});
test("both dead is all down", () => {
const records = [[10, "mail1.example.com"], [20, "mail2.example.com"]];
const ips = { "mail1.example.com": ["203.0.113.10"], "mail2.example.com": [] };
const ports = { "mail1.example.com": "refused", "mail2.example.com": "no_dns" };
const result = classifyMxHealth(records, ips, ports);
assert.equal(result.all_down, true);
});
Case studies
The self hosted mail server nobody decommissioned in DNS
A small company moved from a self hosted mail server to a hosted provider two years earlier. The migration went fine, new mail flowed through the new MX host, but the old server's MX record, still at a lower priority number, was never removed. The old VPS was eventually canceled for non payment.
Some sending mail servers, following standard fallback order, still tried the old, higher priority host first, got connection refused, and only reached the working host on retry, if their software retried at all. A script that probed both MX hosts on port 25 found the dead one immediately, and deleting that record cleared up delivery delays that had been blamed on spam filtering for months.
The mail host that timed out instead of refusing
A domain's single MX record pointed at a mail server behind a firewall that had been reconfigured to drop inbound traffic on port 25 instead of rejecting it outright. DNS resolution worked perfectly, so nobody thought to check further. Every outbound sender's connection attempt just hung until it timed out.
Because a timeout looks nothing like an immediate DNS failure, the domain owner only found out when a client mentioned an email had not arrived in two days. Running the SMTP probe against the resolved IP showed the hang directly. Fixing the firewall rule to allow port 25 again, and confirming the port answered with a proper banner, resolved it without needing to touch DNS at all.
After the fix, dig +short MX example.com only shows hostnames you have personally confirmed answer on port 25 with a proper 220 banner. A real test email sent from an external account arrives without a bounce or delay warning. Re-checking from a couple of public resolvers, such as 8.8.8.8 and 1.1.1.1, confirms the change has propagated everywhere, not just on your own machine.
FAQ
Why does mail keep failing when the MX record looks fine?
DNS only tells a sending server where to try, not whether anything is listening there. If the MX hostname still resolves to an IP address but nothing answers on port 25, the DNS layer reports success while the actual delivery attempt gets refused or times out. That failure only shows up when a real message is sent, not when someone just checks the DNS record.
Why does mail take a day or two to bounce instead of failing right away?
Per RFC 5321, sending mail servers are supposed to retry a failed delivery for a while before giving up, commonly 24 to 48 hours. Each retry hits the same dead host and fails the same way, so the sender queues the message quietly and only sends a bounce notice after the retry window expires.
Can the sender just fall back to the domain's normal website address instead?
No. RFC 5321 says mail must only be delivered to the addresses published in the MX record, or to the domain's own address record if there is no MX record at all. A sending server will not fall back to the website's A record just because the MX host is unreachable, so a dead MX target blocks mail even though the website works fine.
Related field notes
Citations
On the problem:
- RFC 5321: Simple Mail Transfer Protocol, MX record resolution and delivery rules. rfc-editor.org/rfc/rfc5321
- Is it acceptable for outbound email server IPs listed in MX records to lack port 25 connectivity? suped.com/knowledge
- How to test SMTP connectivity of your MX servers, CaptainDNS. captaindns.com/en/blog
On the solution:
- Cloudflare API docs: DNS Records, Edit (PATCH). developers.cloudflare.com/api
- Cloudflare DNS docs: manage DNS records, create and edit MX records. developers.cloudflare.com/dns
- How to check MX records using the dig command, linuxconfig.org. linuxconfig.org
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