Diagnostic Mail Routing
MX record has no A or AAAA record
The MX record for a domain looks fine. It names a mail server, it has a preference number, it has been there for months. But mail is bouncing or queuing forever. The reason is often simple and easy to miss: the hostname the MX record points at has no A or AAAA record of its own, so every sending mail server has a name but nowhere to connect it to. Here is why that happens and how to add the missing record.
An MX record only names a hostname that should receive mail, it does not carry an IP address itself. A sending mail server has to look up an A or AAAA record for that exact hostname next, and if that record was never created, was deleted, or points at a CNAME instead, address resolution fails and mail bounces or queues until it times out. The fix is to add a real A record, and an AAAA record if the server has IPv6, for the exact MX target hostname, not a CNAME. Full commands and a small watcher script are below.
The problem in plain words
Think of an MX record as a signpost, not a street address. It tells the world "mail for this domain should go to mail.example.com," but it does not say where mail.example.com actually is. To find that out, a sending mail server has to do one more lookup: it asks for the A record (for IPv4) or the AAAA record (for IPv6) of that exact hostname.
Per RFC 5321 section 5, this second lookup is a required step of mail delivery, not an optional one. If the hostname named in the MX record has no A or AAAA record, that lookup comes back empty. The sending server now has a name it cannot turn into an address, so it cannot open a connection at all. It is not that the mail server is down or slow. There is simply no address to try.
Why it happens
- The MX record was created before the address record, and nobody went back to add the A or AAAA record for the mail host.
- The A record for the mail host was deleted during a cleanup or migration, but the MX record pointing at it was left behind.
- The mail host was mistakenly given a CNAME instead of an A record. RFC 2181 and RFC 5321 both say an MX target must not be a CNAME, and some resolvers refuse it outright rather than following the alias.
- The MX target has a typo, so it names a hostname that is close to the real one but does not exist in the zone at all.
All of these look identical from the outside: mail sent to the domain either bounces immediately or sits queued at the sending server until it gives up and reports a delivery failure back to the original sender.
An MX record and an address record answer two different questions, and mail delivery needs both answered. The MX record answers "which hostname handles mail for this domain." The A or AAAA record answers "what IP address does that hostname have." A domain can have a perfectly correct MX record and still have broken mail, because the second question was never answered for that exact hostname.
The fix, as a flow
Read the MX records to get the exact target hostnames, then check each one for an A or AAAA record. Rule out a CNAME sitting where an A record should be. Query the authoritative nameservers directly so a cached answer cannot fool you. Then add the missing address record, and confirm it is not hidden behind a proxy that would break SMTP.
How to fix it
Get the exact MX target hostnames
Read the domain's MX records first. Each answer has a preference number and a target hostname, for example 10 mail.example.com.. Write down every target, since a domain can have more than one and each needs its own address record.
dig +short MX example.com
# example answer:
# 10 mail.example.com.
Check the target for an A and an AAAA record
For each target hostname, ask for its A record and its AAAA record. A bad result is empty output for both, meaning that host has no address at all and cannot be reached by any sending server.
dig +short A mail.example.com
dig +short AAAA mail.example.com
# a broken target returns nothing for either line
Rule out a CNAME masking as the cause
RFC 2181 and RFC 5321 both forbid an MX target from being a CNAME. If the target hostname has a CNAME instead of an A record, some resolvers will refuse to use it at all rather than follow the alias.
dig mail.example.com CNAME
# if this returns an answer, the MX target is a CNAME, which is not allowed
Query the authoritative nameservers directly
Bypass caches entirely by asking the zone's own nameservers. This confirms whether the address record is truly missing in the zone, rather than just missing from a resolver's stale cache.
dig +short NS example.com
dig @ns1.example.com MX example.com
dig @ns1.example.com A mail.example.com
dig +trace mail.example.com A
Add the missing A and, if needed, AAAA record
Create the address record at the exact MX target hostname. Do not point the MX target at a CNAME. If the mail server is also reachable over IPv6, add an AAAA record as well.
mail.example.com 3600 IN A 203.0.113.25
mail.example.com 3600 IN AAAA 2001:db8::25
; Name: mail
; Value: 203.0.113.25 (your mail server's real IP)
; TTL: 3600 (or Auto)
If the target was a CNAME, replace it correctly
If mail.example.com was set up as CNAME mailhost.provider.net, either replace that with a direct A/AAAA record, or change the MX record itself to point at mailhost.provider.net, a name the provider guarantees has its own address record, instead of at your CNAME alias.
; option A: remove the CNAME, add a direct A record instead
mail.example.com 3600 IN A 203.0.113.25
; option B: point the MX record itself at the provider host
example.com 3600 IN MX 10 mailhost.provider.net.
If using Cloudflare, set the record to DNS only
A proxied (orange cloud) A record resolves to Cloudflare's proxy IP, which handles web traffic and does not accept SMTP. Make sure the new record's proxy status is DNS only (grey cloud), or mail will still fail even though the A record now exists.
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" \
-H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"type":"A","name":"mail.example.com","content":"203.0.113.25","ttl":3600,"proxied":false}'
How to check it worked
Re-run the address lookup for every MX target and confirm each one returns a real IP. Then check the way a sending mail server actually checks, MX first and then A for each returned host. Finally confirm the mail port itself answers, since a resolvable IP is not proof the server is listening.
dig +short A mail.example.com
dig +short AAAA mail.example.com
dig +short MX example.com
dig +short A mail.example.com
nc -vz mail.example.com 25
openssl s_client -connect mail.example.com:25 -starttls smtp
# good result: a real routable IP for every target, and the SMTP
# port answers with a banner or completes the STARTTLS handshake
The full code
Here is a small script that resolves a domain's MX records, then resolves A and AAAA for each target hostname, and flags any target where both lookups come back empty as a dangling MX. When it finds one, it can create the missing A record through the Cloudflare API, with the new record set to DNS only so SMTP still works. It stays in dry run by default so it reports before it writes.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect an MX target with no A/AAAA record (a dangling MX) and repair it
via Cloudflare. Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("mx_target_missing_address_record")
def find_dangling_mx_targets(mx_targets: list[str], resolved_addresses: dict[str, list[str]]) -> list[str]:
"""mx_targets: list of MX target hostnames (e.g. ['mail.example.com']).
resolved_addresses: mapping of hostname -> list of A/AAAA IPs already looked up (empty list if none/NXDOMAIN).
Returns the subset of mx_targets that have no A or AAAA address (dangling MX), preserving order, deduplicated."""
seen = set()
dangling = []
for host in mx_targets:
if host in seen:
continue
seen.add(host)
if not resolved_addresses.get(host):
dangling.append(host)
return dangling
def run():
# Imported lazily so the pure function above can be tested with no
# network libraries installed at all.
import dns.resolver
import dns.exception
import requests
domain = os.environ["DNS_DOMAIN"]
fallback_ip = os.environ.get("RECORD_TARGET", "203.0.113.25")
ttl = int(os.environ.get("RECORD_TTL", "3600"))
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
api_token = os.environ["CLOUDFLARE_API_TOKEN"]
mx_answer = dns.resolver.resolve(domain, "MX")
mx_targets = [str(r.exchange).rstrip(".") for r in mx_answer]
log.info("Found %d MX target(s) for %s: %s", len(mx_targets), domain, mx_targets)
resolved_addresses = {}
for host in mx_targets:
addresses = []
for rtype in ("A", "AAAA"):
try:
answer = dns.resolver.resolve(host, rtype)
addresses.extend(str(r) for r in answer)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
pass
resolved_addresses[host] = addresses
dangling = find_dangling_mx_targets(mx_targets, resolved_addresses)
if not dangling:
log.info("Every MX target for %s has an A or AAAA record. Nothing to repair.", domain)
return
for host in dangling:
log.info("MX target %s has no A/AAAA record. %s create an A record pointing to %s.",
host, "Would" if dry_run else "Will", fallback_ip)
if dry_run:
continue
resp = requests.post(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"},
json={"type": "A", "name": host, "content": fallback_ip, "ttl": ttl, "proxied": False},
timeout=30,
)
resp.raise_for_status()
log.info("Created A record for %s -> %s (DNS only)", host, fallback_ip)
if __name__ == "__main__":
run()
/**
* Detect an MX target with no A/AAAA record (a dangling MX) and repair it
* via Cloudflare. Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
*/
import { pathToFileURL } from "node:url";
export function findDanglingMxTargets(mxTargets, resolvedAddresses) {
// mxTargets: array of MX target hostnames (e.g. ["mail.example.com"]).
// resolvedAddresses: object mapping hostname -> array of A/AAAA IPs already
// looked up (empty array if none/NXDOMAIN).
// Returns the subset of mxTargets with no A or AAAA address (dangling MX),
// preserving order, deduplicated.
const seen = new Set();
const dangling = [];
for (const host of mxTargets) {
if (seen.has(host)) continue;
seen.add(host);
const addresses = resolvedAddresses[host] || [];
if (addresses.length === 0) dangling.push(host);
}
return dangling;
}
export async function run() {
// Imported lazily so the pure function above can be tested with no
// network modules touched at all.
const dns = await import("node:dns");
const resolvePromises = dns.promises;
const domain = process.env.DNS_DOMAIN;
const fallbackIp = process.env.RECORD_TARGET || "203.0.113.25";
const ttl = Number(process.env.RECORD_TTL || 3600);
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const mxRecords = await resolvePromises.resolveMx(domain);
const mxTargets = mxRecords.map((r) => r.exchange.replace(/\.$/, ""));
console.log(`Found ${mxTargets.length} MX target(s) for ${domain}: ${mxTargets.join(", ")}`);
const resolvedAddresses = {};
for (const host of mxTargets) {
const addresses = [];
for (const resolveFn of [resolvePromises.resolve4, resolvePromises.resolve6]) {
try {
const answers = await resolveFn.call(resolvePromises, host);
addresses.push(...answers);
} catch (err) {
if (err.code !== "ENOTFOUND" && err.code !== "ENODATA") throw err;
}
}
resolvedAddresses[host] = addresses;
}
const dangling = findDanglingMxTargets(mxTargets, resolvedAddresses);
if (dangling.length === 0) {
console.log(`Every MX target for ${domain} has an A or AAAA record. Nothing to repair.`);
return;
}
for (const host of dangling) {
console.log(`MX target ${host} has no A/AAAA record. ${dryRun ? "Would" : "Will"} create an A record pointing to ${fallbackIp}.`);
if (dryRun) continue;
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
method: "POST",
headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ type: "A", name: host, content: fallbackIp, ttl, proxied: false }),
});
if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
console.log(`Created A record for ${host} -> ${fallbackIp} (DNS only)`);
}
}
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 finder function is the part worth testing on its own, because it decides which MX targets get treated as broken. It takes plain lists and a plain mapping, so the test needs no network and no DNS library at all.
from mx_target_missing_address_record import find_dangling_mx_targets
def test_no_dangling_when_every_target_has_an_address():
targets = ["mail.example.com"]
resolved = {"mail.example.com": ["203.0.113.25"]}
assert find_dangling_mx_targets(targets, resolved) == []
def test_flags_target_with_empty_address_list():
targets = ["mail.example.com"]
resolved = {"mail.example.com": []}
assert find_dangling_mx_targets(targets, resolved) == ["mail.example.com"]
def test_flags_target_missing_from_the_mapping():
targets = ["mail.example.com"]
resolved = {}
assert find_dangling_mx_targets(targets, resolved) == ["mail.example.com"]
def test_preserves_order_and_dedupes():
targets = ["b.example.com", "a.example.com", "b.example.com"]
resolved = {"a.example.com": [], "b.example.com": []}
assert find_dangling_mx_targets(targets, resolved) == ["b.example.com", "a.example.com"]
def test_mixed_targets_only_flags_the_broken_one():
targets = ["good.example.com", "bad.example.com"]
resolved = {"good.example.com": ["203.0.113.1"], "bad.example.com": []}
assert find_dangling_mx_targets(targets, resolved) == ["bad.example.com"]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDanglingMxTargets } from "./mx-target-missing-address-record.js";
test("no dangling when every target has an address", () => {
const targets = ["mail.example.com"];
const resolved = { "mail.example.com": ["203.0.113.25"] };
assert.deepEqual(findDanglingMxTargets(targets, resolved), []);
});
test("flags target with empty address list", () => {
const targets = ["mail.example.com"];
const resolved = { "mail.example.com": [] };
assert.deepEqual(findDanglingMxTargets(targets, resolved), ["mail.example.com"]);
});
test("flags target missing from the mapping", () => {
const targets = ["mail.example.com"];
const resolved = {};
assert.deepEqual(findDanglingMxTargets(targets, resolved), ["mail.example.com"]);
});
test("preserves order and dedupes", () => {
const targets = ["b.example.com", "a.example.com", "b.example.com"];
const resolved = { "a.example.com": [], "b.example.com": [] };
assert.deepEqual(findDanglingMxTargets(targets, resolved), ["b.example.com", "a.example.com"]);
});
test("mixed targets only flags the broken one", () => {
const targets = ["good.example.com", "bad.example.com"];
const resolved = { "good.example.com": ["203.0.113.1"], "bad.example.com": [] };
assert.deepEqual(findDanglingMxTargets(targets, resolved), ["bad.example.com"]);
});
Case studies
The mail host that got retired without telling DNS
A team decommissioned an old mail server and cleaned up its A record as part of the teardown, but the MX record pointing at that hostname was on a different checklist and never got touched. For weeks, some outbound senders queued mail for hours before giving up, while others bounced right away depending on how patient their own retry logic was.
A quick dig +short A mail.example.com against the MX target came back completely empty. Re-adding an A record pointing at the new mail server's IP fixed delivery within the TTL window, no MX record change needed.
The CNAME that quietly broke mail during a CDN switch
A site moved its main domain behind a CDN and, to keep things simple, pointed every subdomain including mail.example.com at the CDN's CNAME target during the migration. Web traffic worked fine. Mail did not, because the CDN's edge only speaks HTTP and the MX target was no longer allowed to be a CNAME in the first place.
Querying the target showed a CNAME where an A record should be. Replacing it with a direct, non-proxied A record pointing at the real mail server restored delivery immediately.
Once the record exists, dig +short MX example.com followed by dig +short A for every returned target comes back with a real, routable IP address, none of the targets are CNAMEs, and the proxy status on that record is DNS only if you use Cloudflare. A test SMTP connection on port 25 completes a banner or STARTTLS handshake instead of timing out. A watcher script like the one above catches a repeat of this the next time a mail host gets retired or migrated without its address record following it.
FAQ
Why does an MX record need a separate A or AAAA record?
An MX record only holds a hostname and a preference number, not an IP address. Per RFC 5321, a sending mail server has to look up that hostname's A or AAAA record to get an address it can connect to. If that address record does not exist, the sending server has nowhere to connect and the mail fails.
Can an MX record point at a CNAME instead of an A record?
No. RFC 2181 and RFC 5321 both say an MX target must not be a CNAME. Some resolvers and mail servers will refuse to use it at all if it is. Point the MX at a name that has its own real A or AAAA record, or point it directly at the provider's hostname instead of your alias for it.
Why does mail still fail after I add the A record in Cloudflare?
Check the proxy status on the new record. A proxied (orange cloud) A record in Cloudflare resolves to Cloudflare's proxy IP, which only handles HTTP traffic and does not accept SMTP connections on port 25. Set the record to DNS only (grey cloud) so it resolves to your real mail server.
Related field notes
Citations
On the problem:
- RFC 5321: Simple Mail Transfer Protocol, section 5, address resolution. rfc-editor.org/rfc/rfc5321.html
- RFC 2181: Clarifications to the DNS Specification, MX target must not be a CNAME. rfc-editor.org/rfc/rfc2181.html
- Cloudflare DNS docs: troubleshooting email issues. developers.cloudflare.com/dns/troubleshooting/email-issues
On the solution:
- Cloudflare DNS docs: troubleshooting email issues. developers.cloudflare.com/dns/troubleshooting/email-issues
- Cloudflare Community: does an MX record require an unproxied A record in order for email to work. community.cloudflare.com/t/does-mx-record-require-an-unproxied-a-record-in-order-for-email-to-work/582193
- Cloudflare API docs: DNS records endpoints. developers.cloudflare.com/api/resources/dns/subresources/records
Stuck on a tricky one?
If you have a DNS, email deliverability, or certificate 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 routing?
If this saved you a pile of bounced mail or a confusing afternoon of DNS debugging, 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