Skip to content

Namecheap DNS: Misfiled MX Records, No Null MX, and Apex CNAME Behaviour

🛑 Problem

Mail for a domain goes somewhere you did not intend. Symptoms vary:

  • Mail addressed to the domain lands on the wrong server and bounces, even though MX records for the right provider appear to exist in the DNS dashboard.
  • Inbound mail through a provider (Mailgun, Fastmail, etc.) has never worked, despite SPF, DKIM and DMARC all being configured correctly.
  • You want the domain to accept no mail at all and cannot make a null MX stick.

🔍 Diagnosis

Step 1 — Compare what the API/dashboard stores against what DNS actually answers

These can disagree, and that disagreement is the bug.

dig +noall +answer MX example.com @dns1.registrar-servers.com
dig +noall +answer CNAME example.com @dns1.registrar-servers.com

Query the authoritative nameserver directly. A recursive resolver hides the record structure behind caching.

Step 2 — Look at the name field of each record

This is the failure that hides in plain sight. In Namecheap's zone editor (and API), the host/name field is relative to the domain. @ means the apex. Putting the full domain there does not mean "the apex" — it appends:

What you typed in name Where the record actually lives
@ example.com ✅
mail mail.example.com ✅
example.com example.com.example.com ❌

Confirm a suspected doubled name directly:

dig +short MX example.com.example.com @dns1.registrar-servers.com

If that returns your mail provider's servers, you have found it. Those records resolve, look correct in a record listing, and do nothing — no mail flow ever consults that name.

Step 3 — Understand what an apex CNAME does to the rest of the zone

Standard DNS (RFC 1034) says a CNAME must be the only record at a name. Namecheap allows an apex CNAME anyway, and serves it alongside other types:

Observed behaviour: Namecheap returns type-specific apex records when they exist, and falls back to the apex CNAME only when the queried type is absent. Verified for TXT and MX.

So with an apex CNAME → othersite.com and no MX of your own, an MX query follows the CNAME and returns the other domain's MX — which is how mail silently ends up at a server that does not host your domain.

# apex CNAME present, no MX of its own:
dig +noall +answer MX example.com
# example.com.  1799  IN  CNAME  othersite.com.
# othersite.com. 300  IN  MX     10 mail.othersite.com.   <-- not yours

☠️ The record set you can read is not the record set that is served

(Added 2026-09-07 after this nearly destroyed a live zone's mail records.)

namecheap.domains.dns.getHosts does not return every record the nameservers answer with. Records generated by the account's EmailType setting — the Mail Settings dropdown, e.g. FWD for free Email Forwarding — are served but are not host records:

# what the API says the zone contains:
#   @       CNAME  majorshouse.com.
#   www     CNAME  majorshouse.com.
#   _dmarc  TXT    "v=DMARC1; p=reject; ..."
#   -> 3 records

dig +short MX example.com @dns1.registrar-servers.com
# eforward1.registrar-servers.com.   <-- served, and NOT in getHosts
# eforward2.registrar-servers.com.
# ... 5 MX records plus an SPF TXT, none of them host records

This defeats the obvious mitigation for setHosts being destructive. The careful-looking approach — read the current records, then write them all back with your one change — still silently deletes the mail records, because they were never in what you read. You lose records you never saw and never omitted on purpose.

Same failure mode as iptables-restore on a Docker host

A "restore everything" operation discards whatever it was never told about. Docker's dynamically-managed chains vanish on an iptables-restore; Namecheap's EmailType-generated MX/SPF vanish on a setHosts that omits EmailType. In both cases the command did exactly what it promised and the operator's model of "everything" was incomplete.

The safe pattern — read EmailType off the result and pass it back explicitly, assert the record count before writing, and diff every address afterward:

res = get_hosts(sld, tld)
email_type = res.attrib["EmailType"]          # e.g. "FWD" — MUST be echoed back
hosts      = [h.attrib for h in res.findall("host")]
assert len(hosts) == EXPECTED, "record count changed — refuse to write"

params = {"Command": "namecheap.domains.dns.setHosts",
          "SLD": sld, "TLD": tld,
          "EmailType": email_type}            # omitting this resets mail handling
for i, h in enumerate(hosts, 1):
    params[f"HostName{i}"]   = h["Name"]
    params[f"RecordType{i}"] = h["Type"]
    params[f"Address{i}"]    = h["Address"]
    params[f"TTL{i}"]        = new_ttl        # the only field changing
    if h["Type"] == "MX":
        params[f"MXPref{i}"] = h.get("MXPref", "10")

Then verify against DNS, not against the API's own success flag:

dig +short MX example.com @dns1.registrar-servers.com | wc -l   # expect the original count
dig +short TXT example.com @dns1.registrar-servers.com | grep -c spf1

Take a rollback artifact first. Dump the host records and EmailType to a file before any setHosts; getHosts cannot recover what the write removed.


✅ Fix

If the MX records are misfiled — correct the name to @

Add before you delete. If the apex refuses the new records, you have lost nothing.

// 1. add corrected records
{ "type": "MX", "name": "@", "exchange": "mxa.mailgun.org", "preference": 10, "ttl": 1799 }
{ "type": "MX", "name": "@", "exchange": "mxb.mailgun.org", "preference": 10, "ttl": 1799 }

// 2. verify on BOTH nameservers, then delete the doubled-name pair
{ "type": "MX", "name": "example.com", "exchange": "mxa.mailgun.org", "preference": 10 }
{ "type": "MX", "name": "example.com", "exchange": "mxb.mailgun.org", "preference": 10 }
dig +short MX example.com @dns1.registrar-servers.com
dig +short MX example.com @dns2.registrar-servers.com

The apex CNAME can stay. Because Namecheap serves type-specific apex records alongside it, adding MX at @ does not require removing the CNAME — which matters when that CNAME is carrying something else, such as pointing the site at a dynamic-IP host via another domain's A record.

If you want the domain to accept no mail — you cannot use a null MX here

[!warning] Namecheap BasicDNS rejects . as an MX exchange RFC 7505 null MX (MX 0 .) is not supported. Attempts fail with business.gatewayConflict.

Established by isolation, so the cause is not guesswork:

Attempt Result
MX @ → "." refused
MX _test → "." refused
MX _test → "example.com" saved

The apex CNAME is not the blocker — the . value is. Do not retry it, and do not reach for Mail Settings → "No Email Service" either: that only removes MX records at @, so an apex CNAME then makes resolution fall back to the target domain's MX and you are back where you started.

Workable alternatives: point MX at a host that refuses mail, or leave DNS alone and handle the unwanted traffic at the receiving server.


⚠️ Key Notes

  • Wait ~30 seconds before judging a change. Immediately after a write, dig against the authoritative nameserver still returns the old answer. In testing, a new MX appeared at roughly t+30s and was stable after. The same lag applies to deletes. An immediate dig reads exactly like a failed change — poll for a minute before concluding anything.
  • Check both nameservers (dns1 and dns2.registrar-servers.com). They converge, but not necessarily in the same instant.
  • Misfiled records are invisible in a casual review. A record list showing MX → mxa.mailgun.org looks right. The bug is in the column most people skim past.
  • A working SPF/DKIM/DMARC set proves nothing about inbound. Outbound authentication and inbound routing are independent. A domain can send perfectly through a provider for years while its inbound MX has never once been consulted.
  • If using the Namecheap MCP (https://mcp.namecheap.com/mcp): dns_records_save is incremental — it adds records and updates TTLs, it does not replace the zone. This is unlike the legacy namecheap.domains.dns.setHosts API call, which does replace the entire host-record set and will wipe records you omit.
  • Do not pass force: true to dns_records_save. After writing, it re-reads the zone to confirm records persisted, because a provider can accept a record and then silently drop it. force opts out of that check and reports success regardless — which is how you end up believing a record is live when it is not.

🔎 Quick Diagnostic Commands

# What the authoritative servers actually answer
dig +noall +answer MX    example.com @dns1.registrar-servers.com
dig +noall +answer CNAME example.com @dns1.registrar-servers.com
dig +short         TXT   example.com @dns1.registrar-servers.com

# The doubled-name check
dig +short MX example.com.example.com @dns1.registrar-servers.com

# Poll after a change instead of trusting one immediate query
for i in 1 2 3 4; do dig +short MX example.com @dns1.registrar-servers.com; sleep 30; done

# Confirm a mail change did not break the website or outbound auth
dig +short A example.com
curl -sL -o /dev/null -w '%{http_code}\n' https://example.com
dig +short TXT example.com          # SPF
dig +short TXT _dmarc.example.com   # DMARC