Repair Mail Routing

MX record malformed with stray characters or a missing FQDN

Someone typed or scripted an MX record and it did not come out right. The target has an @ sign in it, maybe pasted in from an email address by mistake, or it is a raw IP address instead of a hostname. It looks close enough to correct that nobody notices at a glance, but mail servers read it literally, and a value like that is not a real hostname. Inbound mail starts bouncing or just goes quiet. Here is why that happens and a script that finds it and fixes it.

Python and Node.js Cloudflare DNS API Safe by default (dry run)
A technician by server racks
Photo by Sammyayot254 on Unsplash
The short answer

An MX record's exchange field, the part after the priority number, must be a plain, dot-terminated hostname, nothing else. RFC 1035 and RFC 974 define it that way, and RFC 2181 adds that the hostname must resolve to an A or AAAA record and must not be a CNAME. When the field instead holds a stray "@" sign, a full email address, or a bare IP literal like "203.0.113.10", most mail servers cannot resolve it, and inbound mail is deferred or bounced. The fix is to delete the bad record and recreate it with a clean, resolvable FQDN and the same priority. Full code, tests, and a dry run guard are below.

The problem in plain words

An MX record has two parts, a priority number and a target hostname. The target tells other mail servers where to deliver mail for your domain. It has to be a real, resolvable hostname, the same kind of thing you would put in an A record, not an email address and not an IP address.

When someone edits DNS by hand, or a script builds the record from a template, it is easy to paste in the wrong value. A support email address like "postmaster@mail.example.com" gets typed into the target field instead of just "mail.example.com". Or someone reasons "the mail server's IP is 203.0.113.10, so I will just put that there" and skips the hostname entirely. Both look plausible in a dashboard, but neither one is a valid MX target, and most receiving mail transfer agents will refuse to use them.

Sender looks up MX for example.com MX target "@mail.example.com" not a valid hostname A/AAAA lookup fails Cannot route 550 5.4.4 Mail bounces or is deferred
The MX record points somewhere that is not a hostname. The sending server cannot turn that value into an A or AAAA answer, so the message never gets an address to deliver to.

Why it happens

RFC 1035 and RFC 974 define the MX RDATA exchange field as a domain name, full stop. It is not a mailbox string and not an address literal. RFC 2181 goes further and says that name has to resolve to an A or AAAA record, and it must not be a CNAME. A value that violates any of that is not a formatting nitpick, it is a record most mail transfer agents cannot use at all.

The key insight

An MX record is not "the address of the mail server." It is a pointer to a hostname that itself has an A or AAAA record. Two lookups have to work, not one: the MX lookup has to return a hostname, and that hostname then has to resolve to an address. If the MX target is an "@" string or a raw IP, the second lookup can never succeed, because there is no valid name to look up.

The fix, as a flow

Pull the MX records for the domain and look at the raw target string for each one. Check it the same way a strict resolver would, no "@" characters, not an IPv4 or IPv6 literal, and a proper dot-separated hostname shape. If a target fails that check, delete the bad record at the DNS host and recreate it with a clean FQDN, keeping the same priority number. Then confirm the new hostname actually has an A or AAAA record and is not itself a CNAME, since a technically well-formed hostname that does not resolve is just a different flavor of the same problem.

Fetch MX records for domain Read each exchange target Valid FQDN, no @, no IP? Repair needed? yes no, leave alone Delete bad record, create clean FQDN target
A domain whose MX target is already a clean, resolvable hostname is left alone. A domain with an "@" sign, an IP literal, or a broken shape gets its record replaced with a corrected FQDN at the same priority.

How to fix it

1

Look at the raw MX target string

Query the MX records directly and read the target exactly as published. A healthy result is an integer priority, a space, then a dot-terminated hostname. Anything with an "@" in it, a bare IP address, or no trailing dot is worth a closer look.

Terminal
check-mx.sh
dig +short MX example.com

# healthy: 10 mail.example.com.
# malformed: 10 @mail.example.com.
# malformed: 10 mail@example.com
# malformed: 10 203.0.113.10

# a second opinion from a different tool
nslookup -type=MX example.com

# confirm resolvers agree with the zone data
dig +trace MX example.com
2

Confirm the target does not resolve as a hostname

Try to resolve the MX target itself as an A or AAAA record. If the "hostname" is really an IP literal or has an "@" in it, this lookup comes back empty or NXDOMAIN, which proves the exchange field is not a usable FQDN. Also check that the target is not a CNAME, which RFC 2181 forbids for MX targets.

Terminal
check-target.sh
# should return an IP address; empty or NXDOMAIN confirms the bug
dig +short A mail.example.com
dig +short AAAA mail.example.com

# should return nothing; an MX target must not be a CNAME
dig +short CNAME mail.example.com
3

Delete the bad record and recreate it with a clean FQDN

At the DNS host, remove the malformed MX record and add a new one with the same priority and a plain hostname target, no "@", no user info, no IP address. If mail is hosted by a provider like Google Workspace or Microsoft 365, use the exact FQDN that provider publishes.

DNS record
records-before-and-after
# BEFORE (bug): stray @ sign in the MX target
example.com.    MX   10   @mail.example.com.

# BEFORE (bug): bare IP literal instead of a hostname
example.com.    MX   10   203.0.113.10

# AFTER: a clean, dot-terminated hostname, same priority
example.com.    MX   10   mail.example.com.

# a mail provider example, if hosted by Google Workspace
example.com.    MX   5    smtp.google.com.
4

Publish the correction through the Cloudflare API (or dashboard)

In the Cloudflare dashboard this is DNS, Records: delete the malformed MX record, then add a new one with type MX, name "@" for the apex, priority the same number as before, and content the corrected hostname. The same steps work through the API.

Terminal
cloudflare-api.sh
# 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}'

# delete the malformed record by its id
curl -X DELETE \
  -H "Authorization: Bearer $CF_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID"

# then create the corrected record, same priority, clean FQDN
curl -X POST \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
  -d '{"type":"MX","name":"example.com","content":"mail.example.com","priority":10,"ttl":3600}'

How to check it worked

Re-run the MX lookup and confirm a clean, dot-terminated hostname with no "@" and no raw IP. Then confirm the target itself resolves to an address and is not a CNAME. Finally send a real test email and check for a clean delivery with no routing bounce.

Terminal
verify.sh
# should print a clean "10 mail.example.com." style line
dig +short MX example.com

# should return an IP address, not empty
dig +short A mail.example.com
dig +short AAAA mail.example.com

# should return nothing; still not a CNAME
dig +short CNAME mail.example.com

A good result is a clean, dot-terminated hostname from the MX lookup, and an actual address from the A or AAAA lookup on that hostname. As a last check, send a real test email to an address at the domain and confirm it arrives, or use an external tool like MXToolbox MX Lookup to confirm there are no syntax warnings and no bounce like "550 5.4.4 Unable to route" or "temporary failure in name resolution."

The full code

Here is the complete checker and repair script in one file for each language. It reads the MX records for a domain, validates each target against the FQDN rules from RFC 1035 and RFC 2181, and, only when you turn dry run off, replaces a bad record through the Cloudflare API.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

mx_record_malformed_target.py
"""Detect an MX record whose target is malformed (a stray @ sign,
a raw IP literal, or an otherwise invalid hostname), and optionally
repair the zone via Cloudflare by replacing it with a clean FQDN.

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_record_malformed_target")

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"


def validate_mx_target(target):
    """Pure decision function. No I/O.

    target: the raw MX exchange string, e.g. "mail.example.com." or
    "@mail.example.com" or "203.0.113.10".

    Returns (True, "") if target is a syntactically valid FQDN: no "@"
    character, not an IPv4 or IPv6 literal, each label 1-63 characters
    of letters, digits, or hyphens (not starting or ending with a
    hyphen), total length 253 characters or less, and at least one dot.

    Otherwise returns (False, reason), where reason is one of
    "contains_at_sign", "is_ip_literal", "invalid_label", or
    "missing_dot".
    """
    import ipaddress

    if not target:
        return (False, "missing_dot")

    value = target.strip()
    if value.endswith("."):
        value = value[:-1]

    if "@" in value:
        return (False, "contains_at_sign")

    try:
        ipaddress.ip_address(value)
        return (False, "is_ip_literal")
    except ValueError:
        pass

    if "." not in value:
        return (False, "missing_dot")

    if len(value) > 253:
        return (False, "invalid_label")

    labels = value.split(".")
    for label in labels:
        if not (1 <= len(label) <= 63):
            return (False, "invalid_label")
        if label.startswith("-") or label.endswith("-"):
            return (False, "invalid_label")
        if not all(ch.isalnum() or ch == "-" for ch in label):
            return (False, "invalid_label")

    return (True, "")


def fetch_mx_records(domain):
    """Return a list of (preference, 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 target_resolves(hostname):
    """Return True if the hostname has an A or AAAA record and is not a CNAME."""
    import dns.resolver
    import dns.exception

    try:
        dns.resolver.resolve(hostname, "CNAME")
        return False  # RFC 2181: an MX target must not be a CNAME
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
        pass

    for rtype in ("A", "AAAA"):
        try:
            dns.resolver.resolve(hostname, rtype)
            return True
        except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.exception.DNSException):
            continue
    return False


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 create_mx_record(domain, content, priority):
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    body = {"type": "MX", "name": domain, "content": content, "priority": priority, "ttl": 3600}
    if DRY_RUN:
        log.info("[dry run] would create MX record: priority %s content %s", priority, content)
        return
    r = requests.post(
        f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
        headers=headers, json=body, timeout=30,
    )
    r.raise_for_status()
    log.info("Created MX record: priority %s content %s", priority, content)


def run():
    records = fetch_mx_records(DNS_DOMAIN)
    if not records:
        log.info("No MX records found for %s.", DNS_DOMAIN)
        return

    bad = []
    for preference, exchange in records:
        ok, reason = validate_mx_target(exchange)
        if not ok:
            bad.append((preference, exchange, reason))
            log.warning("Malformed MX target for %s: %r (%s)", DNS_DOMAIN, exchange, reason)
        elif not target_resolves(exchange.rstrip(".")):
            log.warning("MX target %r for %s does not resolve to an A/AAAA record.", exchange, DNS_DOMAIN)

    if not bad:
        log.info("All %d MX record(s) for %s look like valid FQDNs.", len(records), DNS_DOMAIN)
        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)
    bad_contents = {exchange.rstrip(".") for _, exchange, _ in bad}
    for rec in zone_records:
        if rec["content"].rstrip(".") in bad_contents:
            delete_record(rec["id"])
            corrected = input(
                f"Enter the corrected hostname for priority {rec['priority']} "
                f"(was {rec['content']!r}): "
            ).strip()
            ok, reason = validate_mx_target(corrected)
            if not ok:
                log.error("Refusing to publish %r, still invalid (%s).", corrected, reason)
                continue
            create_mx_record(DNS_DOMAIN, corrected, rec["priority"])
    log.info("Done.")


if __name__ == "__main__":
    run()
mx-record-malformed-target.js
/**
 * Detect an MX record whose target is malformed (a stray @ sign,
 * a raw IP literal, or an otherwise invalid hostname), and optionally
 * repair the zone via Cloudflare by replacing it with a clean FQDN.
 *
 * 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 { 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 CF_API = "https://api.cloudflare.com/client/v4";

function isIpLiteral(value) {
  // IPv4: four dot-separated 0-255 groups.
  const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
  const m = value.match(ipv4);
  if (m) {
    return m.slice(1, 5).every((part) => Number(part) >= 0 && Number(part) <= 255);
  }
  // IPv6: contains a colon and only hex digits, colons, and optional "::".
  if (value.includes(":")) {
    return /^[0-9a-fA-F:]+$/.test(value);
  }
  return false;
}

export function validateMxTarget(target) {
  // Pure decision function. No I/O.
  //
  // target: the raw MX exchange string, e.g. "mail.example.com." or
  // "@mail.example.com" or "203.0.113.10".
  //
  // Returns [true, ""] if target is a syntactically valid FQDN: no "@"
  // character, not an IPv4 or IPv6 literal, each label 1-63 characters
  // of letters, digits, or hyphens (not starting or ending with a
  // hyphen), total length 253 characters or less, and at least one dot.
  //
  // Otherwise returns [false, reason], where reason is one of
  // "contains_at_sign", "is_ip_literal", "invalid_label", or
  // "missing_dot".
  if (!target) return [false, "missing_dot"];

  let value = target.trim();
  if (value.endsWith(".")) value = value.slice(0, -1);

  if (value.includes("@")) return [false, "contains_at_sign"];
  if (isIpLiteral(value)) return [false, "is_ip_literal"];
  if (!value.includes(".")) return [false, "missing_dot"];
  if (value.length > 253) return [false, "invalid_label"];

  const labels = value.split(".");
  for (const label of labels) {
    if (label.length < 1 || label.length > 63) return [false, "invalid_label"];
    if (label.startsWith("-") || label.endsWith("-")) return [false, "invalid_label"];
    if (!/^[A-Za-z0-9-]+$/.test(label)) return [false, "invalid_label"];
  }

  return [true, ""];
}

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 targetResolves(hostname) {
  const dns = await import("node:dns/promises");
  try {
    await dns.resolveCname(hostname);
    return false; // RFC 2181: an MX target must not be a CNAME
  } catch {
    // no CNAME, keep checking
  }
  for (const method of ["resolve4", "resolve6"]) {
    try {
      await dns[method](hostname);
      return true;
    } catch {
      continue;
    }
  }
  return false;
}

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 createMxRecord(domain, content, priority) {
  const headers = {
    Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
    "Content-Type": "application/json",
  };
  if (DRY_RUN) {
    console.log(`[dry run] would create MX record: priority ${priority} content ${content}`);
    return;
  }
  const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records`, {
    method: "POST",
    headers,
    body: JSON.stringify({ type: "MX", name: domain, content, priority, ttl: 3600 }),
  });
  if (!res.ok) throw new Error(`Cloudflare create returned ${res.status}`);
  console.log(`Created MX record: priority ${priority} content ${content}`);
}

async function run() {
  const records = await fetchMxRecords(DNS_DOMAIN);
  if (records.length === 0) {
    console.log(`No MX records found for ${DNS_DOMAIN}.`);
    return;
  }

  const bad = [];
  for (const [preference, exchange] of records) {
    const [ok, reason] = validateMxTarget(exchange);
    if (!ok) {
      bad.push([preference, exchange, reason]);
      console.warn(`Malformed MX target for ${DNS_DOMAIN}: ${JSON.stringify(exchange)} (${reason})`);
    } else if (!(await targetResolves(exchange.replace(/\.$/, "")))) {
      console.warn(`MX target ${JSON.stringify(exchange)} for ${DNS_DOMAIN} does not resolve to an A/AAAA record.`);
    }
  }

  if (bad.length === 0) {
    console.log(`All ${records.length} MX record(s) for ${DNS_DOMAIN} look like valid FQDNs.`);
    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);
  const badContents = new Set(bad.map(([, exchange]) => exchange.replace(/\.$/, "")));
  for (const rec of zoneRecords) {
    if (badContents.has(rec.content.replace(/\.$/, ""))) {
      await deleteRecord(rec.id);
      console.log(
        `Record for priority ${rec.priority} (was ${JSON.stringify(rec.content)}) deleted. ` +
        `Set CLOUDFLARE_API_TOKEN and re-run createMxRecord with the corrected hostname.`,
      );
    }
  }
  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 validation rule is the part most worth testing, because it decides whether a target is trusted as a real hostname or flagged as broken. Because validate_mx_target is pure, the test needs no network and no DNS provider. It just feeds in plain strings and checks the verdict.

test_mx_validate.py
from mx_record_malformed_target import validate_mx_target


def test_clean_hostname_is_valid():
    assert validate_mx_target("mail.example.com.") == (True, "")


def test_clean_hostname_without_trailing_dot_is_valid():
    assert validate_mx_target("mail.example.com") == (True, "")


def test_at_sign_prefix_is_rejected():
    ok, reason = validate_mx_target("@mail.example.com.")
    assert ok is False
    assert reason == "contains_at_sign"


def test_mailbox_string_is_rejected():
    ok, reason = validate_mx_target("user@mail.example.com")
    assert ok is False
    assert reason == "contains_at_sign"


def test_ipv4_literal_is_rejected():
    ok, reason = validate_mx_target("203.0.113.10")
    assert ok is False
    assert reason == "is_ip_literal"


def test_ipv6_literal_is_rejected():
    ok, reason = validate_mx_target("2001:db8::1")
    assert ok is False
    assert reason == "is_ip_literal"


def test_single_label_with_no_dot_is_rejected():
    ok, reason = validate_mx_target("mailserver")
    assert ok is False
    assert reason == "missing_dot"


def test_label_starting_with_hyphen_is_rejected():
    ok, reason = validate_mx_target("-mail.example.com")
    assert ok is False
    assert reason == "invalid_label"
mx-record-malformed-target.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { validateMxTarget } from "./mx-record-malformed-target.js";

test("clean hostname is valid", () => {
  assert.deepEqual(validateMxTarget("mail.example.com."), [true, ""]);
});

test("clean hostname without trailing dot is valid", () => {
  assert.deepEqual(validateMxTarget("mail.example.com"), [true, ""]);
});

test("at sign prefix is rejected", () => {
  const [ok, reason] = validateMxTarget("@mail.example.com.");
  assert.equal(ok, false);
  assert.equal(reason, "contains_at_sign");
});

test("mailbox string is rejected", () => {
  const [ok, reason] = validateMxTarget("user@mail.example.com");
  assert.equal(ok, false);
  assert.equal(reason, "contains_at_sign");
});

test("ipv4 literal is rejected", () => {
  const [ok, reason] = validateMxTarget("203.0.113.10");
  assert.equal(ok, false);
  assert.equal(reason, "is_ip_literal");
});

test("ipv6 literal is rejected", () => {
  const [ok, reason] = validateMxTarget("2001:db8::1");
  assert.equal(ok, false);
  assert.equal(reason, "is_ip_literal");
});

test("single label with no dot is rejected", () => {
  const [ok, reason] = validateMxTarget("mailserver");
  assert.equal(ok, false);
  assert.equal(reason, "missing_dot");
});

test("label starting with hyphen is rejected", () => {
  const [ok, reason] = validateMxTarget("-mail.example.com");
  assert.equal(ok, false);
  assert.equal(reason, "invalid_label");
});

Case studies

Copy paste mistake

The postmaster address that ended up in the MX target

An admin was setting up mail for a new domain and had "postmaster@mail.example.com" copied to the clipboard from a support ticket. When adding the MX record, that whole string got pasted into the target field instead of just "mail.example.com". The record saved without any warning from the dashboard.

Inbound mail from several external senders started bouncing with routing errors within a day. A direct dig +short MX lookup showed the "@" sign immediately. Deleting the record and recreating it with the plain hostname fixed delivery within minutes of the change propagating.

Migration shortcut

The IP address that seemed like a shortcut

During a server migration, someone updated the MX record to point straight at the new mail server's IP address, reasoning that since the IP was already known and reachable, adding a hostname was an unnecessary extra step. The record looked fine in a quick glance at the dashboard.

A handful of large mail providers immediately began deferring messages with a "unable to route" style error, since their software followed the RFC and refused to treat an IP literal as a valid MX target. Publishing a proper "mail.example.com" A record and pointing the MX target at that hostname instead resolved the deferrals on the next retry.

What good looks like

After the fix, dig +short MX example.com returns a clean, dot-terminated hostname with no "@" sign and no raw IP address. That hostname resolves to an A or AAAA record on its own, and it is not a CNAME. A real test email arrives without a routing bounce, and an external checker like MXToolbox reports no syntax warnings on the record.

FAQ

Why does an @ sign in an MX record break inbound mail?

RFC 1035 says the MX exchange field must be a domain name, not a mailbox string. When the target contains an @ sign, such as one pasted in from an email address, it is no longer a valid hostname. Most receiving mail servers cannot resolve it to an A or AAAA record, so they defer or bounce the message instead of delivering it.

Can an MX record point straight at an IP address instead of a hostname?

No. RFC 1035 and RFC 974 define the MX exchange field as a domain name that itself must resolve to an A or AAAA record. A bare IPv4 or IPv6 literal in that field is not a hostname, so mail servers that follow the standard will not deliver to it, even though the address might be reachable on the network.

Can an MX record point at a CNAME instead of a real hostname?

No. RFC 2181 says an MX target must resolve directly to an A or AAAA record and must not be a CNAME. If the hostname you point to is itself an alias, some resolvers will still work but many mail servers will refuse it, so the safer fix is to point the MX record at the real hostname that holds the A or AAAA record.

Related field notes

Citations

On the problem:

  1. RFC 1035: Domain Names, Implementation and Specification, MX RDATA format. rfc-editor.org/rfc/rfc1035
  2. RFC 2181: Clarifications to the DNS Specification, MX target must not be a CNAME. rfc-editor.org/rfc/rfc2181
  3. Cloudflare Learning Center: What is a DNS MX record? cloudflare.com/learning/dns/dns-records/dns-mx-record

On the solution:

  1. Cloudflare DNS docs: set up email records (MX). developers.cloudflare.com/dns
  2. Cloudflare DNS docs: DNS record types reference. developers.cloudflare.com/dns
  3. Cloudflare API docs: DNS Records, Create/Update record. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes