Skip to content

Checking MX records to see if a domain can receive email

· 6 min read · Email validation

To check whether a domain can receive email, query its MX records. If there are MX records, it can (unless the only one is the "null MX" 0 ., which means it explicitly can't). If there are no MX records but the domain has an A or AAAA record, mail still goes to that address under RFC 5321's implicit MX rule. If the domain doesn't exist, it can't. Any other DNS failure means "unknown", and you should not reject a user over it.

Most MX-check snippets get two of those cases wrong. They treat "no MX" as "no mail", and they treat a timeout as "invalid domain". Here's how to get all of them right.

The five outcomes of an MX lookup

DNS result Example (at time of writing) Can receive mail?
One or more MX records gmail.com Yes
A single MX with preference 0 and target . example.com No, explicitly (null MX)
No MX records, but A/AAAA exist bücher.de Yes, via the implicit MX
NXDOMAIN (name doesn't exist) any unregistered domain No
SERVFAIL, timeout, refused broken or slow nameservers Unknown

You can see each case with dig:

dig +short MX gmail.com
# 5 gmail-smtp-in.l.google.com.
# 10 alt1.gmail-smtp-in.l.google.com.
# ...

dig +short MX example.com
# 0 .

dig +short MX xn--bcher-kva.de   # bücher.de in punycode: no output, no MX
dig +short A  xn--bcher-kva.de   # but it has A records

The implicit MX rule

RFC 5321 section 5.1 says that when a lookup returns no MX records, the domain "is treated as if it was associated with an implicit MX RR, with a preference of 0, pointing to that host". A sending server will try to deliver to the domain's own address. The same section is explicit that this fallback "applies only if there are no MX records present". If MX records exist, senders must use them.

This matters because small domains, especially ones that run mail on the same box as the website, sometimes have no MX at all. Rejecting them is a false positive. That said, an A record doesn't guarantee anything is listening on port 25. The implicit MX tells you mail will be attempted, not that it will land.

Null MX

RFC 7505 defines a way for a domain to say "we don't do email": a single MX record with preference 0 and an empty exchange, written 0 .. A domain that publishes it "MUST NOT advertise any other MX RR". Senders that hit a null MX fail immediately instead of retrying for days, and the RFC suggests reply code 556 with enhanced status 5.1.10, "Recipient address has null MX".

For validation, a null MX is the strongest "no" DNS can give you. It's stronger than NXDOMAIN, because it's a deliberate statement by a domain that exists. Some parked and web-only domains publish it.

Watch out: a naive "has any MX answer" check counts a null MX as a pass. Node returns it as { exchange: '', priority: 0 }, dnspython as an exchange of .. You have to look for it.

NXDOMAIN versus everything else

NXDOMAIN is an authoritative answer: the name doesn't exist. You can reject on it, with a friendly "check for a typo" message.

SERVFAIL, timeouts and refused queries are not answers about the domain. They're answers about the path between you and the domain's nameservers. Lame delegations, DNSSEC misconfigurations, a resolver having a bad minute: all look the same from your side. If you block on those, you block real users at exactly the moments their DNS provider is having trouble. Return "unknown" and let the confirmation email decide.

Node.js implementation

This uses the built-in resolver with a timeout. No dependencies.

import { promises as dns } from 'node:dns';

const resolver = new dns.Resolver({ timeout: 3000, tries: 2 });

// Returns 'yes' | 'no' | 'unknown' plus a reason you can log.
export async function canReceiveMail(domain) {
  try {
    const mx = await resolver.resolveMx(domain);
    if (mx.length === 1 && mx[0].exchange === '') {
      return { result: 'no', reason: 'null_mx' }; // RFC 7505: "MX 0 ."
    }
    if (mx.length > 0) {
      mx.sort((a, b) => a.priority - b.priority);
      return { result: 'yes', reason: 'mx', hosts: mx.map((r) => r.exchange) };
    }
  } catch (err) {
    if (err.code === 'ENOTFOUND') return { result: 'no', reason: 'nxdomain' };
    if (err.code !== 'ENODATA') return { result: 'unknown', reason: err.code };
  }

  // No MX records: RFC 5321 section 5.1 implicit MX, fall back to A/AAAA.
  const [a, aaaa] = await Promise.allSettled([
    resolver.resolve4(domain),
    resolver.resolve6(domain),
  ]);
  if (a.status === 'fulfilled' || aaaa.status === 'fulfilled') {
    return { result: 'yes', reason: 'implicit_mx' };
  }
  const codes = [a, aaaa].map((r) => r.reason?.code);
  if (codes.every((c) => c === 'ENODATA' || c === 'ENOTFOUND')) {
    return { result: 'no', reason: 'no_mx_no_address' };
  }
  return { result: 'unknown', reason: codes.join(',') };
}

Running it against the examples above returns mx for gmail.com, null_mx for example.com, implicit_mx for xn--bcher-kva.de and nxdomain for an unregistered name. Two things to note: resolveMx throws ENODATA when the name exists but has no MX, and ENOTFOUND for NXDOMAIN. Everything else (ETIMEOUT, ESERVFAIL, ECONNREFUSED) lands in "unknown".

Pass the domain in punycode. node:dns won't convert bücher.de for you.

Python implementation

With dnspython (pip install dnspython):

import dns.resolver
import dns.exception

resolver = dns.resolver.Resolver()
resolver.lifetime = 3.0  # total seconds per query

def can_receive_mail(domain: str) -> tuple[str, str]:
    try:
        answer = resolver.resolve(domain, "MX")
        records = sorted(answer, key=lambda r: r.preference)
        if len(records) == 1 and records[0].exchange.to_text() == ".":
            return "no", "null_mx"
        return "yes", "mx"
    except dns.resolver.NXDOMAIN:
        return "no", "nxdomain"
    except dns.resolver.NoAnswer:
        pass  # no MX: try the implicit MX rule below
    except (dns.resolver.NoNameservers, dns.exception.Timeout) as exc:
        return "unknown", type(exc).__name__

    for rtype in ("A", "AAAA"):
        try:
            resolver.resolve(domain, rtype)
            return "yes", "implicit_mx"
        except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
            continue
        except (dns.resolver.NoNameservers, dns.exception.Timeout) as exc:
            return "unknown", type(exc).__name__
    return "no", "no_mx_no_address"

NoNameservers is what dnspython raises when every nameserver returned SERVFAIL or refused, so it belongs with timeouts in the "unknown" bucket.

Things an MX check won't tell you

  • Whether the mailbox exists. nobody-here-12345@gmail.com passes. Mailbox existence needs the mail server's cooperation, which you mostly won't get; see why SMTP mailbox verification is unreliable.
  • Whether the domain is a throwaway. Disposable inbox providers have impeccable MX records. That's a classification problem, not a DNS one.
  • Whether the MX hosts work. An MX can point at a hostname that doesn't resolve or a server that's been switched off. You could resolve each exchange too, but at that point you're building a mail client. A confirmation email tests the whole path for free.
  • Whether it's a typo. gmai.com publishes MX records at the time of writing.

The full map of which check catches what is in email validation vs verification.

Running this in production

Cache results by domain. MX records change rarely, and signups tend to cluster on a small number of big providers, so most lookups should be cache hits. Respect a sensible TTL and cache "unknown" results only briefly, if at all. Caching domain lookups goes into keys and TTLs.

If your code runs somewhere without a normal resolver (edge functions, some serverless runtimes), you can do the same queries over HTTPS. DNS-over-HTTPS for domain checks in serverless code shows how, including how status codes map onto the table above.

If you'd rather not run DNS yourself, pingutil returns these facts as domain_signals when you pass check_dns: true:

curl -X POST https://pingutil.com/v1/lookup \
  -H "Authorization: Bearer pk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "input": "jane@bücher.de", "skip_enrichment": true, "check_dns": true }'
# "domain_signals": { "domain_exists": true, "can_receive_email": true, "has_website": true }

can_receive_email is a coarse flag: true when the domain has MX records or, following the implicit MX rule, an apex A record. It doesn't separate out null MX, so if that distinction matters to you, run the MX query yourself with the code above. The signals are cached for seven days and never change verdict; the policy is yours. Field definitions are in the API reference.

The rule of thumb: reject on NXDOMAIN and null MX, accept on MX or A/AAAA, and never reject on "unknown".

Questions people ask

Can a domain receive email without an MX record?
Yes. If a domain has no MX records, RFC 5321 tells senders to treat its A or AAAA record as an implicit MX with preference 0. Mail to such a domain is delivered to that address, if a mail server is listening there.
What does an MX record of 0 dot mean?
That is a null MX, defined in RFC 7505. A domain publishes MX 0 . to state that it does not accept email at all. Treat it as a definite no.
Does a valid MX record mean the email address exists?
No. It only means the domain has somewhere to deliver mail. Whether a specific mailbox exists can only be confirmed by the mail server, and most servers will not tell you reliably.