A DNS lookup that does not return records has not necessarily told you the domain is bad. It might have told you the resolver could not answer. Those are different facts, they arrive as different status codes, and they require opposite handling. Most email validators built in a hurry collapse them into one if (!records) reject branch.

The two status codes

DNS-over-HTTPS returns a JSON object with a numeric Status field, carrying the RCODE from RFC 1035:

  • Status 3, NXDOMAIN. The authoritative nameserver for the parent zone says this name does not exist. This is a definitive negative answer. outlook.con returns it, because .con is not a delegated top-level domain.
  • Status 2, SERVFAIL. Something in the resolution path broke. A DNSSEC validation failure, an unresponsive authoritative server, a timeout, a lame delegation. The resolver is reporting its own inability to answer, and says nothing about whether the domain exists.

gmial.com returns SERVFAIL. That is a real, reproducible result against a public resolver, not a transient blip — a persistently broken delegation looks the same as a momentary outage from the client's side.

Why collapsing them is worse than it looks

Consider the naive branch:

// Wrong. Two different facts, one response.
const answer = await resolveMx(domain);
if (answer.Status !== 0 || !answer.Answer) {
  return { verdict: "undeliverable", reason: "domain_not_found" };
}

Run this during a resolver incident. Every domain you check, including gmail.com, returns Status 2 for the duration. Your validator now reports every address your users type as undeliverable, your signup form rejects everyone, and your logs show a clean domain_not_found for each one. The failure is invisible in aggregate metrics because the verdict distribution looks like a sudden wave of bad traffic rather than an outage.

Now invert it. Some implementations, having been burned by exactly that, flip to accepting on any error. Those systems then accept genuinely nonexistent domains whenever the resolver is unhappy, and the addresses land in the database looking fully validated.

Neither direction is safe, because the question "should I accept this" has no single correct answer for a lookup that did not complete. The only correct move is to stop pretending you know.

Fail open, but say so in the response

nobounce splits the two cases and encodes the split in the verdict object:

DNS result verdict reason checked.mx
NXDOMAIN (Status 3) undeliverable domain_not_found true
SERVFAIL (Status 2) unknown dns_error false
NOERROR, null MX undeliverable null_mx true
NOERROR, no MX and no A/AAAA undeliverable no_mx_no_a true

The verdict unknown paired with checked.mx: false is the contractual fail-open signal. It means: syntax and typo checks ran and found nothing wrong, the DNS check did not complete, and this is not an endorsement. A degraded check is never reported as deliverable, because the entire value of a deliverable verdict is that it was actually earned.

That field exists for a conversation you will eventually have. A customer asks why a bad address was accepted six weeks ago. With checked.mx recorded alongside the verdict you can answer honestly: the lookup did not complete, we said unknown, your integration chose to proceed. Without it, you are guessing.

Never cache a failure

The second half of the rule matters as much as the first. A lookup that failed must not write a cache row.

If SERVFAIL is cached with the same time-to-live as a successful negative answer, a brief resolver problem pins a wrong verdict for the whole cache window. Users who signed up during a thirty-second incident keep getting rejected for hours, long after DNS recovered. RFC 2308 governs negative caching for definitive negative answers; a server failure is not one of those.

nobounce verifies this behaviourally rather than by inspection: after checking a SERVFAIL domain, the domain cache table has no row for it. Successful verdicts cache for six hours positive and one hour negative, shared across all customers. Failures cache for zero seconds.

Handling unknown in your own code

Fail-open is only a coherent policy if the caller has a policy too. Branch on the verdict rather than on truthiness:

const result = await check(email);

if (result.verdict === "undeliverable" && result.suggestion) {
  return offerCorrection(result.suggestion);
}
if (result.verdict === "undeliverable") {
  return rejectWithReason(result.reason);
}
// unknown, risky and deliverable all proceed. Record the verdict so a later
// audit can distinguish "we verified this" from "we could not verify this".
return acceptSignup({ emailVerdict: result.verdict, mxChecked: result.checked.mx });

Validation should never become a hard dependency of signup. If the validator is down, users still get in — and you keep a record of which ones arrived unverified, so a cleanup pass is possible later. That is a very different position from having silently accepted them with no marker at all.

The frozen taxonomy of verdicts and reasons is served at /v1/config, so an integration can assert against the exact enum rather than string-matching prose.