email-utils
API reference

@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
ParameterType
domainstring
Returns

Promise<string[]>

resolve6()
resolve6(domain): Promise<string[]>;
Parameters
ParameterType
domainstring
Returns

Promise<string[]>

resolveMx()
resolveMx(domain): Promise<object[]>;
Parameters
ParameterType
domainstring
Returns

Promise<object[]>

resolveTxt()
resolveTxt(domain): Promise<string[][]>;
Parameters
ParameterType
domainstring
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
5000
query?
optional query?: number;

How long each lookup may take before it counts as timed out.

Default Value
2000

DnsValidator

Every function here, with options, resolver, and cache bound.

Methods

check()
check(emailOrDomain, options?): Promise<Result<DnsSignals>>;

checkDns with the validator's options.

Parameters
ParameterType
emailOrDomainstring
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
ParameterType
emailOrDomainstring
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
ParameterType
emailOrDomainstring
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
ParameterType
emailOrDomainstring
options?DnsCallOptions
Returns

Promise<Result<SmtpProbe>>

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
ParameterType
emailOrDomainstring
options?DnsCallOptions
Returns

Promise<Result<DnsScore>>

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
30000
resolver?
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

DnsOptions.scoreModel

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

DnsOptions.signal

smtp?
optional smtp?: SmtpOptions;

How probeSmtp probes; the other functions ignore it.

Inherited from

DnsOptions.smtp

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

DnsOptions.syntax

timeout?
optional timeout?: DnsTimeout;

Time budgets for the lookups. A lookup that runs over is ETIMEOUT.

Inherited from

DnsOptions.timeout


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 end
ports?
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
10000
untilAccepted?
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
false

SmtpPortProbe

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

ParameterType
emailOrDomainstring
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.de
import { 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

ParameterType
options?DnsValidatorOptions

Returns

DnsValidator

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

ParameterType
emailOrDomainstring
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

ParameterType
emailOrDomainstring
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

ParameterType
emailOrDomainstring
options?DnsOptions

Returns

Promise<Result<SmtpProbe>>

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

ParameterType
emailOrDomainstring
options?DnsOptions

Returns

Promise<Result<DnsScore>>

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 TypeError

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.

On this page

InterfacesDnsCallOptionsPropertiessignal?DnsOptionsExtended byPropertiesscoreModel?Default Valuesignal?smtp?syntax?Default Valuetimeout?DnsResolverMethodsresolve4()ParametersReturnsresolve6()ParametersReturnsresolveMx()ParametersReturnsresolveTxt()ParametersReturnsDnsScorePropertiescontributionsmodelidversionprobabilitysignalsDnsScoreModelPropertiescoefficientshasA?hasAaaa?hasMx?hasSpf?implicitMx?knownProvider?multipleMx?mxHosts?nullMx?idinterceptversionDnsSignalsPropertieshasAhasAaaahasMxhasSpfimplicitMxmxHostsnullMxDnsTimeoutPropertiesoverall?Default Valuequery?Default ValueDnsValidatorMethodscheck()ParametersReturnsThrowsdetectProviderByMx()ParametersReturnsThrowsisValid()ParametersReturnsThrowsprobeSmtp()ParametersReturnsThrowsscore()ParametersReturnsThrowsDnsValidatorOptionsExtendsPropertiescacheTtl?Default Valueresolver?Default ValuescoreModel?Default ValueInherited fromsignal?Inherited fromsmtp?Inherited fromsyntax?Default ValueInherited fromtimeout?Inherited fromSmtpOptionsPropertiesehloName?Default Valueports?Default Valuetimeout?Default ValueuntilAccepted?Default ValueSmtpPortProbePropertiescode?hostmessage?outcomeportSmtpProbePropertiesacceptedprobesType AliasesReasonCodeResultType ParametersScoreFeatureSmtpOutcomeFunctionscheckDns()ParametersReturnsRemarksExamplesThrowscreateDnsValidator()ParametersReturnsRemarksExampleThrowsdetectProviderByMx()ParametersReturnsRemarksExampleThrowsisValidDns()ParametersReturnsExampleThrowsprobeSmtp()ParametersReturnsRemarksExampleThrowsscoreDns()ParametersReturnsRemarksExamplesThrows