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.

dig and Cloudflare API Python and Node.js Fixable through the DNS API
A network device
Photo by Elimende Inagella on Unsplash
The short answer

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.

Sending server looks up MX MX: mail.example.com a hostname, not an IP no A or AAAA record No address found nothing to connect to Mail bounces or queues
The MX record only supplies a hostname. Without an A or AAAA record for that exact name, the sending server has no address to connect to, and delivery fails.

Why it happens

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.

The key insight

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.

dig MX example.com get each target host Check A and AAAA for each MX target Rule out a CNAME at the MX target name Address record now exists? yes no, add A/AAAA Test SMTP port grey cloud, port 25
Read the MX targets, confirm each one resolves to a real IP and not a CNAME, add what is missing, then confirm the mail port actually answers.

How to fix it

1

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.

Terminal
check-mx (shell)
dig +short MX example.com

# example answer:
# 10 mail.example.com.
2

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.

Terminal
check-address (shell)
dig +short A mail.example.com
dig +short AAAA mail.example.com

# a broken target returns nothing for either line
3

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.

Terminal
check-cname (shell)
dig mail.example.com CNAME

# if this returns an answer, the MX target is a CNAME, which is not allowed
4

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.

Terminal
check-authoritative (shell)
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
5

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.

DNS record
zone record
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)
6

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.

DNS record
zone record (fix)
; 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.
7

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.

Terminal
create-record (shell)
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.

Terminal
verify (shell)
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.

mx_target_missing_address_record.py
"""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()
mx-target-missing-address-record.js
/**
 * 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.

test_mx_target.py
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"]
mx-target-missing-address-record.test.js
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

Server decommission

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.

CDN migration

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.

What good looks like

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:

  1. RFC 5321: Simple Mail Transfer Protocol, section 5, address resolution. rfc-editor.org/rfc/rfc5321.html
  2. RFC 2181: Clarifications to the DNS Specification, MX target must not be a CNAME. rfc-editor.org/rfc/rfc2181.html
  3. Cloudflare DNS docs: troubleshooting email issues. developers.cloudflare.com/dns/troubleshooting/email-issues

On the solution:

  1. Cloudflare DNS docs: troubleshooting email issues. developers.cloudflare.com/dns/troubleshooting/email-issues
  2. 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
  3. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes