@email-utils/validator-dns
The API of @email-utils/validator-dns 1.0.0-rc.2.
Interfaces
DnsCallOptions
Options for one call to a DnsValidator's methods.
Properties
signal?
optional signal?: AbortSignal;Aborts this call, alongside the validator's own signal; it then
rejects with the signal's reason.
DnsOptions
Options for every function here, and for createDnsValidator.
Extended by
Properties
scoreModel?
optional scoreModel?: DnsScoreModel | "dns-reachability" | "dns-only";The model scoreDns scores with: one of the two bundled fits by
name — 'dns-reachability', which counts an MX the classifier's
provider registry knows, or 'dns-only', which reads the DNS signals
alone — or a model of your own, whose calibration is then yours. Its
intercept and coefficients must be within ±1e6, far past any fitted
model's, so the log-odds can't overflow to NaN.
Default Value
'dns-reachability'
signal?
optional signal?: AbortSignal;Aborts the check, which then rejects with the signal's reason. A
lookup other checks share keeps going for them.
smtp?
optional smtp?: SmtpOptions;How probeSmtp probes; the other functions ignore it.
syntax?
optional syntax?: SyntaxOptions;How an address or domain is parsed before any lookup. allowIdn is on
unless you turn it off or pick the html5 preset, which can't hold IDN
domains. maxLength caps the input before it's trimmed, so longer
input fails unread.
Default Value
the validator-syntax practical preset with allowIdn
timeout?
optional timeout?: DnsTimeout;Time budgets for the lookups. A lookup that runs over is ETIMEOUT.
DnsResolver
The lookups validator-dns makes, as node:dns/promises has them. A
lookup that finds no records may resolve to [] or reject with Node's
ENODATA or ENOTFOUND code; any other rejection is a failed lookup,
and ETIMEOUT a timed-out one.
Methods
resolve4()
resolve4(domain): Promise<string[]>;Parameters
| Parameter | Type |
|---|---|
domain | string |
Returns
Promise<string[]>
resolve6()
resolve6(domain): Promise<string[]>;Parameters
| Parameter | Type |
|---|---|
domain | string |
Returns
Promise<string[]>
resolveMx()
resolveMx(domain): Promise<object[]>;Parameters
| Parameter | Type |
|---|---|
domain | string |
Returns
Promise<object[]>
resolveTxt()
resolveTxt(domain): Promise<string[][]>;Parameters
| Parameter | Type |
|---|---|
domain | string |
Returns
Promise<string[][]>
DnsScore
What scoreDns estimates, and from what.
Properties
contributions
contributions: Partial<Record<ScoreFeature, number>>;Each feature's share of the log-odds, so a score can be explained.
model
model: object;id
id: string;version
version: string;probability
probability: number;The model's estimate of the chance that one of the domain's mail servers accepts a connection, in [0, 1]. For the bundled models, it's calibrated: of the held-out domains scored near 0.9, about 90% accepted.
signals
signals: DnsSignals;DnsScoreModel
A fitted logistic model: the log-odds of acceptance are intercept plus
each coefficient times its feature's value. A true signal is 1, and a
false or undefined one (a failed lookup) is 0; mxHosts is the
number of hosts, and knownProvider and multipleMx are 1 or 0. The
intercept and each coefficient are within ±1e6.
Properties
coefficients
coefficients: object;Fitted log-odds coefficients, not hand-chosen points. A feature left
out, or set to undefined, isn't counted.
hasA?
optional hasA?: number;hasAaaa?
optional hasAaaa?: number;hasMx?
optional hasMx?: number;hasSpf?
optional hasSpf?: number;implicitMx?
optional implicitMx?: number;knownProvider?
optional knownProvider?: number;multipleMx?
optional multipleMx?: number;mxHosts?
optional mxHosts?: number;nullMx?
optional nullMx?: number;id
id: string;intercept
intercept: number;version
version: string;DnsSignals
Everything the lookups learned about the domain.
Properties
hasA
hasA: boolean | undefined;The domain has A records; undefined when the lookup failed.
hasAaaa
hasAaaa: boolean | undefined;The domain has AAAA records; undefined when the lookup failed.
hasMx
hasMx: boolean;The domain has MX records other than a Null MX.
hasSpf
hasSpf: boolean | undefined;A TXT record, its chunks joined, starts with v=spf1 (RFC 7208 §4.5);
undefined when the lookup failed.
implicitMx
implicitMx: boolean;No MX, but A/AAAA serves as the implicit MX (RFC 5321 §5.1).
mxHosts
mxHosts: string[];The MX hosts, lowercased and without the trailing dot, in preference order; empty when there are none, including for an implicit MX.
nullMx
nullMx: boolean;The domain publishes a Null MX (RFC 7505): an MX record whose host is
.. On its own, it fails the check with dns.mx.null; next to other MX
records, which RFC 7505 forbids, it's ignored.
DnsTimeout
Time budgets in milliseconds.
Properties
overall?
optional overall?: number;How long the whole check may take; lookups still in flight then count
as timed out. The lookups run at once, each held to the lower of
query and overall, so overall only has an effect when it's lower
than query. With the defaults, a check settles within about 2
seconds.
Default Value
5000query?
optional query?: number;How long each lookup may take before it counts as timed out.
Default Value
2000DnsValidator
Every function here, with options, resolver, and cache bound.
Methods
check()
check(emailOrDomain, options?): Promise<Result<DnsSignals>>;checkDns with the validator's options.
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsCallOptions |
Returns
Promise<Result<DnsSignals>>
Throws
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
the validator's signal or options.signal aborts, it rejects with the
signal's reason.
detectProviderByMx()
detectProviderByMx(emailOrDomain, options?): Promise<Result<string | undefined>>;detectProviderByMx with the validator's options, sharing its
MX answers with check.
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsCallOptions |
Returns
Promise<Result<string | undefined>>
Throws
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
the validator's signal or options.signal aborts, it rejects with the
signal's reason.
isValid()
isValid(emailOrDomain, options?): Promise<boolean>;Exactly (await check(emailOrDomain, options)).ok.
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsCallOptions |
Returns
Promise<boolean>
Throws
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
the validator's signal or options.signal aborts, it rejects with the
signal's reason.
probeSmtp()
probeSmtp(emailOrDomain, options?): Promise<Result<SmtpProbe>>;probeSmtp with the validator's options, sharing its lookups
with check.
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsCallOptions |
Returns
Throws
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
the validator's signal or options.signal aborts, it closes the
connections and rejects with the signal's reason.
score()
score(emailOrDomain, options?): Promise<Result<DnsScore>>;scoreDns with the validator's options, sharing its lookups
with check.
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsCallOptions |
Returns
Throws
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
the validator's signal or options.signal aborts, it rejects with the
signal's reason.
DnsValidatorOptions
Options for createDnsValidator.
Extends
Properties
cacheTtl?
optional cacheTtl?: number;How long an answer stays cached, in milliseconds; 0 caches nothing,
though checks still share a lookup in flight. Failed lookups are never
cached.
Default Value
30000resolver?
optional resolver?: DnsResolver;Where the lookups go.
Default Value
node:dns/promises
scoreModel?
optional scoreModel?: DnsScoreModel | "dns-reachability" | "dns-only";The model scoreDns scores with: one of the two bundled fits by
name — 'dns-reachability', which counts an MX the classifier's
provider registry knows, or 'dns-only', which reads the DNS signals
alone — or a model of your own, whose calibration is then yours. Its
intercept and coefficients must be within ±1e6, far past any fitted
model's, so the log-odds can't overflow to NaN.
Default Value
'dns-reachability'
Inherited from
signal?
optional signal?: AbortSignal;Aborts the check, which then rejects with the signal's reason. A
lookup other checks share keeps going for them.
Inherited from
smtp?
optional smtp?: SmtpOptions;How probeSmtp probes; the other functions ignore it.
Inherited from
syntax?
optional syntax?: SyntaxOptions;How an address or domain is parsed before any lookup. allowIdn is on
unless you turn it off or pick the html5 preset, which can't hold IDN
domains. maxLength caps the input before it's trimmed, so longer
input fails unread.
Default Value
the validator-syntax practical preset with allowIdn
Inherited from
timeout?
optional timeout?: DnsTimeout;Time budgets for the lookups. A lookup that runs over is ETIMEOUT.
Inherited from
SmtpOptions
How probeSmtp probes each MX host.
Properties
ehloName?
optional ehloName?: string;The name to send with EHLO: a domain, or an address literal such as
[192.0.2.1].
Default Value
the address literal of the connection's local endports?
optional ports?: readonly number[];The ports to probe on each host. 465 speaks TLS from the start (RFC
8314), and a certificate that doesn't verify makes the probe
refused; the rest speak plain SMTP.
Default Value
[25], where MX hosts take mail
timeout?
optional timeout?: number;How long each probe may take, from connecting to the reply to EHLO, in milliseconds. Some servers wait seconds before greeting.
Default Value
10000untilAccepted?
optional untilAccepted?: boolean;Probe the hosts one at a time, in preference order, and stop at the
first that accepts, rather than all at once. accepted means the same;
it takes fewer connections, and longer when hosts don't answer.
Default Value
falseSmtpPortProbe
One probe of one MX host on one port.
Properties
code?
optional code?: number;The last reply's code: EHLO's, or the greeting's. Absent without one.
host
host: string;message?
optional message?: string;What happened, for people. Not semver-stable.
outcome
outcome: SmtpOutcome;port
port: number;SmtpProbe
Every probe of a domain's MX hosts.
Properties
accepted
accepted: boolean;Some probe was accepted.
probes
probes: SmtpPortProbe[];Each host in preference order, and each port in turn for each host.
With untilAccepted, only the hosts tried.
Type Aliases
ReasonCode
type ReasonCode =
| "dns.address.unparsable"
| "dns.domain.not_found"
| "dns.mx.none"
| "dns.mx.null"
| "dns.lookup.timeout"
| "dns.lookup.failed";The dns.* codes from the reason-code catalogue
(https://email-utils.github.io/meta/docs/reference/reason-codes#validator-dns).
Result
type Result<T> =
| {
ok: true;
value: T;
}
| {
message?: string;
ok: false;
reason: ReasonCode;
};A check's result: the value on success, or why the domain can't receive
mail. Branch on reason, never on message, which isn't semver-stable.
Type Parameters
| Type Parameter |
|---|
T |
ScoreFeature
type ScoreFeature = keyof DnsSignals | "knownProvider" | "multipleMx";What a model coefficient may attach to: a signal from DnsSignals,
or one of two values derived from mxHosts — knownProvider, whether
the MX matches a provider in @email-utils/classifier/providers (the
match detectProviderByMx makes), and multipleMx, whether there is
more than one MX host.
SmtpOutcome
type SmtpOutcome = "accepted" | "refused" | "unreachable" | "timeout";How far a probe got: accepted once the server greets with 220 and
answers EHLO with 250; refused when it replies with anything else or
hangs up; unreachable when nothing takes the connection; timeout when
the probe runs out of time.
Functions
checkDns()
function checkDns(emailOrDomain, options?): Promise<Result<DnsSignals>>;Looks up the domain of emailOrDomain and checks it can receive mail:
it has MX records, or A/AAAA records that serve as the implicit MX (RFC
5321 §5.1), and doesn't publish a Null MX (RFC 7505).
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsOptions |
Returns
Promise<Result<DnsSignals>>
Remarks
The input is trimmed and parsed with the syntax options first, and
input that doesn't parse, or has a domain literal, fails with
dns.address.unparsable before any lookup, as does input longer than
syntax.maxLength, 512 characters by default, which isn't read at all:
it's checked before trimming, so surrounding whitespace counts toward it.
A string without an @ is taken as a bare domain. IDN domains are looked
up by their A-labels.
MX, A, AAAA, and TXT are looked up at once, through node:dns/promises,
each within timeout.query and all within timeout.overall. Only a
failed lookup the answer rests on fails the check: MX, and A/AAAA when
there's no MX. Any other failed lookup leaves its signal undefined
rather than false.
Every call shares one cache, which keeps answers for 30 seconds and joins a lookup already in flight; createDnsValidator makes one with its own resolver and TTL.
Examples
import { } from '@email-utils/validator-dns';
const = await ('ada@example.com');
if (.) {
.; // { hasMx: true, nullMx: false, implicitMx: false, hasSpf: true, … }
} else {
.; // e.g. 'dns.mx.null' or 'dns.lookup.timeout'
}
await ('bücher.de'); // looks up xn--bcher-kva.deimport { checkDns } from '@email-utils/validator-dns';
// A domain literal has no records to look up, so none is made.
await checkDns('ada@[192.0.2.1]');
// => { ok: false, reason: 'dns.address.unparsable' }Throws
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
options.signal aborts, it rejects with the signal's reason.
createDnsValidator()
function createDnsValidator(options?): DnsValidator;Binds options once, and returns every function here with them applied
and a cache of its own:
answers are kept for cacheTtl, and checks of one domain share a lookup
in flight, so a thousand concurrent checks make one lookup per record
type.
Parameters
| Parameter | Type |
|---|---|
options? | DnsValidatorOptions |
Returns
Remarks
A check that joins a lookup another check started gets that lookup's answer, but is still held to its own budget and signals. Aborting one check leaves the lookup running for the rest.
Example
import {
createDnsValidator,
type DnsResolver,
} from '@email-utils/validator-dns';
// A stub, as in tests; by default the lookups go to node:dns.
const resolver: DnsResolver = {
resolveMx: async () => [{ exchange: 'smtp.google.com.', priority: 1 }],
resolve4: async () => ['192.0.2.1'],
resolve6: async () => [],
resolveTxt: async () => [['v=spf1 include:_spf.google.com ~all']],
};
const validator = createDnsValidator({
resolver,
timeout: { query: 1000, overall: 3000 },
cacheTtl: 60_000,
});
await validator.check('ada@example.com');
// => { ok: true, value: { hasMx: true, hasSpf: true, mxHosts: ['smtp.google.com'] } }
// From the cached MX answer:
await validator.detectProviderByMx('ada@example.com');
// => { ok: true, value: 'google-workspace' }Throws
TypeError when options are malformed or name an option that
doesn't exist.
detectProviderByMx()
function detectProviderByMx(emailOrDomain, options?): Promise<Result<string | undefined>>;Looks up the MX of emailOrDomain's domain and names the provider that
hosts it, from the MX patterns in @email-utils/classifier/providers:
'google-workspace' for a domain whose MX is smtp.google.com, or
'namecheap' for Namecheap's default forwarding MX.
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsOptions |
Returns
Promise<Result<string | undefined>>
Remarks
The MX hosts are tried in preference order, and the first one the
registry knows names the provider, so a domain behind a filtering
gateway is still found by its backup MX. The ID is the classifier's, so
getProvider and the sanitizer's provider option take it.
The value is undefined when the MX answer names no provider the
registry knows, including a domain with no MX or a Null MX. When it
can't tell, it fails as checkDns does: dns.address.unparsable
for input that doesn't parse, and dns.lookup.timeout or
dns.lookup.failed when the MX lookup does. Parsing, time budgets, and
the shared cache are checkDns's, and only MX is looked up.
Example
import { } from '@email-utils/validator-dns';
const = await ('ada@example.com');
if (.) {
.; // e.g. 'microsoft365', or undefined for no known provider
} else {
.; // e.g. 'dns.lookup.timeout'
}Throws
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
options.signal aborts, it rejects with the signal's reason.
isValidDns()
function isValidDns(emailOrDomain, options?): Promise<boolean>;Whether the domain of emailOrDomain can receive mail: exactly
(await checkDns(emailOrDomain, options)).ok.
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsOptions |
Returns
Promise<boolean>
Example
import { } from '@email-utils/validator-dns';
if (!(await ('ada@example.com'))) {
// No mail there, or a lookup failed; checkDns's `reason` tells which.
}Throws
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
options.signal aborts, it rejects with the signal's reason.
probeSmtp()
function probeSmtp(emailOrDomain, options?): Promise<Result<SmtpProbe>>;checkDns, then an SMTP probe of each of the domain's MX hosts, or of the domain itself for an implicit MX: connect, read the greeting, send EHLO, and QUIT. It never sends MAIL or RCPT, so it learns whether a mail server answers, not whether a mailbox exists.
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsOptions |
Returns
Remarks
Every host is probed on every port in smtp.ports at once, or with
smtp.untilAccepted one host at a time until one accepts, and each
probe is held to smtp.timeout, after the lookups' own budget. A probe's
outcome is data, not a failure: the result fails only as
checkDns does, before any probe.
Many networks block outbound port 25, home ISPs and cloud hosts among
them. From there every probe comes back timeout or unreachable
whatever the domain does, so read those as "couldn't connect from here",
not "the domain is dead".
Example
import { } from '@email-utils/validator-dns';
const = await ('ada@example.com', {
: { : [25, 587], : true },
});
if (.) {
..; // true when some MX host answered EHLO with 250
..; // [{ host: 'mx.example.com', port: 25, outcome: 'accepted', code: 250, … }]
}Throws
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
options.signal aborts, it closes the connections and rejects with the
signal's reason.
scoreDns()
function scoreDns(emailOrDomain, options?): Promise<Result<DnsScore>>;checkDns, then an estimate of how likely the domain's mail servers are to accept a connection, from the DNS signals alone.
Parameters
| Parameter | Type |
|---|---|
emailOrDomain | string |
options? | DnsOptions |
Returns
Remarks
The estimate comes from a logistic model fitted on domains labeled by
probeSmtp, and is calibrated on held-out domains: of those it
scored near 0.9, about 90% accepted. Pick a threshold from the published
precision/recall table for the model's version rather than a fixed pass
mark. contributions shows what each signal added to the log-odds.
Input that checkDns fails, fails here the same way: a domain the RFCs say can't receive mail has no score to give. No probe is made.
Two fitted models are bundled, picked by scoreModel. The default,
'dns-reachability', also counts whether the MX belongs to a provider
the classifier's registry knows, which such domains' near-total
acceptance makes the strongest signal. 'dns-only' reads the DNS
signals alone, for callers who don't want the score to move when the
registry grows. A model object of your own replaces both, and the
calibration with it.
Examples
import { } from '@email-utils/validator-dns';
// What the default model gives typical domains, measured on its held-out
// corpus (the repo's model/ directory has the full tables):
// MX on Google Workspace or Microsoft 365, SPF → ≈ 0.99
// Self-hosted MX, SPF → ≈ 0.75
// Self-hosted MX, no SPF → ≈ 0.62
// No MX, A records only → ≈ 0.02
const = await ('ada@example.com');
if (. && .. >= 0.9) {
// In practice only domains on a registry-known provider score here.
}
if (.) {
..;
// { hasMx: 4.31, hasA: -0.14, …, knownProvider: 3.67 } — why it scored
}
// The same signals, without the registry: self-hosted ≈ 0.81, hosted ≈ 0.9
await ('ada@example.com', { : 'dns-only' });import { scoreDns } from '@email-utils/validator-dns';
// A model of your own is checked before any lookup: its intercept and
// coefficients must be within ±1e6.
await scoreDns('ada@example.com', {
scoreModel: {
id: 'mine',
version: '1.0.0',
intercept: -3,
coefficients: { hasMx: 1e7 },
},
}); // => throws TypeErrorThrows
TypeError, as a rejection, when emailOrDomain isn't a string,
or options are malformed or name an option that doesn't exist. When
options.signal aborts, it rejects with the signal's reason.