Establish a TCP connection to a mail server. Give it a domain or an email address; it resolves the MX records for that target and connects, trying the highest priority server first and falling back through the rest.
Supports unicode hostnames, IPv6, MTA-STS and DANE/TLSA verification.
Node.js 22.19.0 or newer. The floor comes from mailauth, used for MTA-STS policy handling.
npm install mx-connect
const mxConnect = require('mx-connect');
// Promise (recommended)
const connection = await mxConnect(options);
// Callback
mxConnect(options, (err, connection) => { ... });- options is the target domain, an email address, or a configuration object
- callback is optional; a promise is returned either way
const mxConnect = require('mx-connect');
try {
const connection = await mxConnect('user@gmail.com');
console.log('Connection to %s:%s', connection.hostname, connection.port);
// Connection to aspmx.l.google.com:25
connection.socket.pipe(process.stdout);
// 220 mx.google.com ESMTP k11-v6si869487ljk.7 - gsmtp
} catch (err) {
console.error(err);
}The same with a callback:
mxConnect('user@gmail.com', (err, connection) => {
if (err) {
return console.error(err);
}
console.log('Connection to %s:%s', connection.hostname, connection.port);
});Pass a string to use the defaults, or an object to configure. Every property except target is optional.
| Option | Type | Default | Description |
|---|---|---|---|
target |
string | Domain, email address, IP address or IP address literal. Anything valid after an @. Unicode is allowed |
|
port |
number | 25 |
Port to connect to |
maxConnectTime |
number | 5 min | Timeout in milliseconds to establish a connection, applied per MX host |
| Option | Description |
|---|---|
localAddress |
Local IP address to bind to |
localHostname |
Hostname of the local address, used for EHLO |
localAddressIPv4 |
Local address used only for IPv4 connections |
localHostnameIPv4 |
Local hostname used only for IPv4 connections |
localAddressIPv6 |
Local address used only for IPv6 connections |
localHostnameIPv6 |
Local hostname used only for IPv6 connections |
The family-specific options let you bind a different source address per family. localAddress is used as-is whenever it matches the family of the host being connected to; the matching family-specific option is used only when localAddress is unset, or when the host belongs to the other family.
| Option | Type | Default | Description |
|---|---|---|---|
ignoreIPv6 |
boolean | false |
Never use IPv6 for sending. See below |
preferIPv6 |
boolean | false |
Try IPv6 addresses before IPv4 when a host has both |
blockLocalAddresses |
boolean | false |
Refuse local and private scope addresses. See Address validation |
blockReservedNetworks |
boolean | false |
Refuse IANA special-purpose addresses. See Address validation |
nat64Prefixes |
string[] | [] |
NAT64 prefixes your own network runs. See NAT64 on your own prefix |
resolveRecords |
function | DNS resolver returning the records. See Custom DNS resolver | |
resolve |
function | Callback-style DNS resolver. See Custom DNS resolver |
With neither set, native dns.promises is used.
resolveRecords receives a domain and a record type and returns the records:
const connection = await mxConnect({
target: 'user@example.com',
dnsOptions: {
async resolveRecords(domain, type) {
// type is 'MX', 'A', 'AAAA' or 'TXT'
return myResolver.lookup(domain, type);
}
}
});Returning the records directly is fine too, so a resolver backed by a cache does not have to pretend to be asynchronous:
const dnsOptions = {
resolveRecords: (domain, type) => cache.get(`${domain}:${type}`) ?? []
};Throwing, or returning a rejected promise, is how you report a lookup failure. Set err.code to ENOTFOUND or ENODATA to say "no records of this type", which lets resolution fall through to the next step; any other code is treated as a real DNS failure.
Tip
resolveRecords always receives an explicit record type, A lookups included.
The older resolve option takes a callback and is still supported:
const dnsOptions = {
resolve(domain, type, callback) {
// A lookups arrive as resolve(domain, callback), with no type
myResolver.lookup(domain, type, callback);
}
};It is called as resolve(domain, type, callback), except for A records where it is called as resolve(domain, callback) with no type at all. That quirk is why resolveRecords exists; prefer it for new code.
Note
If both are set, resolveRecords is used and resolve is ignored, so you can migrate one deployment at a time.
Setting either option to something that is not a function throws, rather than quietly falling back to the system resolver. A mistyped option would otherwise send mail through a resolver you did not choose, losing whatever yours was there for, and nothing would say so. Leaving an option unset, or setting it to null, is not a mistake and falls through as normal.
Neither option covers TLSA lookups for DANE, which have their own dane.resolveTlsa. Wiring up a single resolver here does not route those through it.
With ignoreIPv6 enabled, no AAAA records are looked up and any IPv6 address is refused rather than used, including one given as the target or supplied through mx. A host that also has an IPv4 address is still delivered to over IPv4.
Note
Refusing an address for this reason says nothing about the destination, only about how you configured this sender, so the error is marked temporary. A message waits for the setting to change instead of bouncing.
Skip MX resolution and connect to a host you name. Accepts a hostname string, a resolved MX object, or an array of either. A string is treated as a hostname or IP address with priority 0.
| Property | Description |
|---|---|
exchange |
Hostname of the MX |
priority |
MX priority, defaulting to 0. Lower numbers are tried first |
A |
Array of IPv4 addresses. Resolved from exchange when omitted |
AAAA |
Array of IPv6 addresses. Resolved from exchange when omitted |
tlsaRecords |
Pre-resolved TLSA records. Resolved automatically when DANE is enabled |
Important
Addresses you supply here are validated exactly like resolved ones. An mx entry pointing at a blocked address is refused, not connected to.
| Option | Type | Description |
|---|---|---|
ignoreMXHosts |
string[] | IP addresses to skip when connecting |
mxLastError |
Error | Error to report if ignoreMXHosts filters out every host |
connectHook |
function | (delivery, options, callback), run before each connection attempt |
connectError |
function | (err, delivery, options), run when a connection to an MX fails |
connectHook runs before the TCP connection is opened. If options has a socket property once the callback returns, mx-connect uses that socket instead of connecting itself. This is how you divert a connection, for example through a SOCKS proxy for an Onion target.
| Option | Description |
|---|---|
enabled |
Set to true to run MTA-STS checks. Off by default |
logger(logObj) |
Receives MTA-STS log entries. Logging is off by default |
cache |
Policy cache with get(domain) and set(domain, policy) |
| Option | Description |
|---|---|
enabled |
Must be set to true to enable DANE. There is no auto-detection |
resolveTlsa(tlsaName) |
Optional custom TLSA resolver. See Custom TLSA resolver |
checkDnssecSecure(host) |
Optional DNSSEC status check. See DNSSEC-aware DANE |
logger(logObj) |
Receives DANE log entries. Logging is off by default |
verify |
Deprecated and ignored. DANE is always enforced when TLSA records are present, per RFC 7672 |
See DANE support for the full picture.
People put every kind of thing in MX records. You do not want to flood your loopback interface because someone decided 127.0.0.1 was a good MX host.
Every address the library resolves or is given passes through one check, whatever its origin: DNS answers, addresses you pass through mx, an IP literal target, and the host a domain's MTA-STS policy is fetched from.
These can never be a real mail host and are refused whatever the options say.
| Range | Description |
|---|---|
0.0.0.0, :: |
Unspecified address |
255.255.255.255 |
Limited broadcast |
224.0.0.0/4, ff00::/8 |
Multicast |
100::/64 |
Discard prefix |
5f00::/16 |
Segment routing |
| Range | Description |
|---|---|
127.0.0.0/8, ::1 |
Loopback |
| RFC 1918 ranges | Private networks |
169.254.0.0/16, fe80::/10 |
Link-local |
100.64.0.0/10 |
Carrier-grade NAT |
fc00::/7 |
IPv6 unique-local |
fec0::/10 |
Deprecated site-local |
64:ff9b:1::/48 |
Local-use NAT64 (see below) |
Any address assigned to one of the machine's own interfaces is refused too.
64:ff9b:1::/48 is refused wholesale because RFC 8215 leaves the position of the embedded IPv4 address up to the local network, so it cannot be read from the address. Declaring the prefix in nat64Prefixes supplies that missing piece and lifts the blanket refusal.
Off by default so the documentation ranges stay usable in tests and staging.
| Range | Description |
|---|---|
240.0.0.0/4 |
Future use |
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32 |
Documentation |
198.18.0.0/15, 2001:2::/48 |
Benchmarking |
2001:3::/32 |
AMT |
An address has to be written in a notation net.isIP() recognises, so 0127.0.0.1, 2130706433 and 127.1 are refused rather than checked.
Warning
Those spellings mean different things to different parsers. A leading zero is octal to one and decimal to another, so 0127.0.0.1 would be checked as the public 87.0.0.1 and then connected to 127.0.0.1.
Records returned by dns.resolve are always canonical, so this only concerns addresses you supply through mx or a custom resolve function.
Several IPv6 forms carry an IPv4 address that the connection actually reaches. Each is judged as the address it reaches, so it can neither slip past blockLocalAddresses nor be refused when the host behind it is an ordinary public one.
| Form | Example |
|---|---|
| IPv4-mapped | ::ffff:127.0.0.1 |
| IPv4-compatible | ::127.0.0.1 |
| NAT64 well-known | 64:ff9b::/96 |
| IPv4-translated | ::ffff:0:0:0/96 |
| 6to4 | 2002::/16 |
| Teredo | 2001::/32 |
So 64:ff9b::7f00:1 is refused because it reaches 127.0.0.1, while 64:ff9b::8.8.8.8 is fine. That distinction matters on IPv6-only networks, where DNS64 synthesizes exactly these records for every IPv4-only mail host, and blocking the prefix outright would break delivery to all of them.
For 6to4 and Teredo both tunnel endpoints are checked as well as the address itself.
Note
ignoreIPv6 is decided separately from this unwrapping, because it is about which stack the socket uses rather than where the packets land. ::ffff:8.8.8.8 and 64:ff9b::8.8.8.8 are refused as IPv6 even though they reach an IPv4 host.
RFC 6052 also lets a network run NAT64 on a prefix from its own address space. Nothing in such an address identifies it as one, so 2a01:4f8:c17:b8f::7f00:1 is indistinguishable from an ordinary IPv6 host.
Only you know that prefix. Declare it and the address it carries is checked like any other:
const connection = await mxConnect({
target: 'user@example.com',
dnsOptions: {
blockLocalAddresses: true,
nat64Prefixes: ['2a01:4f8:c17:b8f::/96']
}
});RFC 7050 describes how to discover the prefix your network uses. Only the prefix lengths RFC 6052 defines (32, 40, 48, 56, 64 and 96) place the address at a known position, so an entry with any other length is ignored, as is one that cannot be parsed.
Refused addresses are collected on delivery.blockedAddresses, which is passed to the connectHook and connectError callbacks. Each entry has exchange, ip and reason.
This matters when only some of a host's addresses are refused: delivery continues over whatever survived, and without this the filtering would leave no trace, so a host left with only unreachable addresses would look like a plain network failure.
Caution
connectHook receives the same options object used to open the socket, so a hook that rewrites options.host connects wherever it says. That is by design, and it is the one path validation does not cover.
The promise resolves, or the callback is invoked, with:
| Property | Description |
|---|---|
socket |
Connected socket |
hostname |
Hostname of the exchange |
host |
IP address connected to |
port |
Port connected to |
localAddress |
Local IP address used |
localHostname |
Local hostname used |
localPort |
Local port used |
daneEnabled |
true when DANE is active for this connection |
daneVerifier |
DANE certificate verification function, for use during the TLS upgrade |
tlsaRecords |
TLSA records for this MX host, when DANE is enabled |
requireTls |
true when TLSA records exist, indicating TLS should be enforced |
Important
mx-connect does not enforce TLS itself. Check requireTls during your TLS upgrade.
A domain that publishes an RFC 7505 null MX record (0 .) is stating that it accepts no mail at all. Resolving such a target fails permanently with err.code = 'ENULLMX' and no A/AAAA fallback is attempted. Treat it as a permanent rejection; retrying will not help.
A null MX published alongside real MX records is a misconfiguration, forbidden by RFC 7505 Section 4.1. The null entry is ignored and delivery proceeds over the remaining records.
DANE (DNS-based Authentication of Named Entities) authenticates TLS certificates through DNSSEC. mx-connect looks up TLSA records for each MX host and gives you a verifier for the server certificate.
Native dns.resolveTlsa is available on every Node.js version this package supports, so DANE needs no resolver configuration:
const connection = await mxConnect({
target: 'user@example.com',
dane: { enabled: true, logger: console.log }
});
console.log('DANE enabled:', connection.daneEnabled);
console.log('TLSA records:', connection.tlsaRecords?.length ?? 0);Note
dane.enabled must be set to true explicitly. There is no auto-detection.
Important
DANE security rests on DNSSEC validation. Without it, an attacker who can tamper with DNS could inject fake TLSA records and pin a certificate of their choosing, making things worse rather than better.
Node.js does not currently expose the DNSSEC AD (Authenticated Data) flag from DNS responses, so an application cannot confirm that TLSA records were DNSSEC-validated by the resolver. This is tracked in nodejs/node#57159.
Applications using a DNS-over-HTTPS resolver, or any DNS library with access to raw response packets, can check the AD flag themselves and feed the result in through checkDnssecSecure.
For production use:
- Use a DNSSEC-validating resolver, such as a local Unbound instance, or 1.1.1.1 or 8.8.8.8
- Consider DNS-over-HTTPS for transport security against on-path attackers, though it is not a substitute for DNSSEC validation
- Provide
checkDnssecSecureif your resolver exposes the AD flag, so TLSA lookups are skipped for insecure zones per RFC 7672 - Watch nodejs/node#57159; when Node exposes the AD flag, this module will be able to require DNSSEC validation
For domains with DNSSEC configured, DANE protects strongly against certificate misissuance and interception. For domains without it, MTA-STS is the alternative or complement.
- DNSSEC check, if
checkDnssecSecureis provided. Hosts in insecure zones skip to step 5. A failing check marks the host as a DANE lookup failure and rejects the connection with a temporary error - TLSA lookup for each MX hostname, for example
_25._tcp.mail.example.com - Connection to the MX server
- TLS upgrade, where you pass
connection.daneVerifierascheckServerIdentity - Certificate verification against the TLSA records, or opportunistic TLS when none were found
Warning
Node only calls checkServerIdentity after its own PKIX validation passes, so with the default rejectUnauthorized: true a certificate failing PKIX is rejected before the DANE verifier ever runs. DANE-EE certificates are frequently self-signed, which is exactly what PKIX rejects. Set rejectUnauthorized: false and treat the verifier's result as the authority.
When TLSA records are present, the verifier's return value is then the only thing standing between you and an intercepted connection. Always destroy the socket when it returns an error.
connection.daneVerifier has the signature (hostname, cert, chain). Node only ever calls checkServerIdentity with (hostname, cert), so wiring it up directly checks the leaf certificate alone, which covers DANE-EE (usage 3) and PKIX-EE (usage 1). To also cover DANE-TA (usage 2) and PKIX-TA (usage 0), call the verifier yourself after the handshake and pass the issuer chain as an array of X509Certificate objects:
const leaf = socket.getPeerX509Certificate();
// the chain holds the issuers only, the leaf is passed separately
const chain = [];
for (let cert = leaf && leaf.issuerCertificate; cert; cert = cert.issuerCertificate) {
chain.push(cert);
}
const err = connection.daneVerifier(hostname, leaf, chain);
if (err) {
socket.destroy();
throw err;
}RFC 7672 Section 2.2.2 requires SMTP clients to check the DNSSEC status of an MX host's address records before looking up TLSA records. TLSA lookups should be skipped for unsigned zones because:
- Secure TLSA records cannot exist in an unsigned zone
- Some nameservers for unsigned zones return SERVFAIL for TLSA queries, Microsoft Exchange Online Protection among them, which would break delivery
checkDnssecSecure receives an MX hostname and returns { secure: boolean }:
const connection = await mxConnect({
target: 'user@example.com',
dane: {
enabled: true,
// Uses the AD flag from the DNS response to decide whether the
// MX host's zone is DNSSEC-signed
async checkDnssecSecure(hostname) {
try {
// A records cover most MX hosts
return await resolver.resolve(hostname, 'A', { dnssecSecure: true });
} catch {
// Fall back to AAAA for IPv6-only hosts
return resolver.resolve(hostname, 'AAAA', { dnssecSecure: true });
}
},
logger: console.log
}
});A zone reported insecure skips the TLSA lookup for that host and falls back to opportunistic TLS. If the callback itself throws, say on a DNS timeout, the host is marked as a DANE lookup failure and the connection is rejected with a temporary error, which keeps DANE enforced across retries rather than silently downgrading. Omitting the callback attempts TLSA lookups for every host.
Tip
You do not need this for compatibility. Every supported Node.js version resolves TLSA records natively. Supply one only when you want the lookups to go through a resolver of your own, typically to obtain DNSSEC validation.
The function receives the full query name and returns an array of TLSA records:
const connection = await mxConnect({
target: 'user@example.com',
dane: {
enabled: true,
async resolveTlsa(tlsaName) {
// tlsaName is e.g. '_25._tcp.mail.example.com'
const answers = await myDohResolver.query(tlsaName, 'TLSA');
return answers.map(answer => ({
usage: answer.usage,
selector: answer.selector,
mtype: answer.matchingType,
cert: Buffer.from(answer.certificate, 'hex')
}));
}
}
});Return an empty array when the host publishes no TLSA records.
{
usage: 3, // 0=PKIX-TA, 1=PKIX-EE, 2=DANE-TA, 3=DANE-EE
selector: 1, // 0=Full certificate, 1=SubjectPublicKeyInfo
mtype: 1, // 0=Full data, 1=SHA-256, 2=SHA-512
cert: Buffer, // Certificate association data
ttl: 3600 // TTL in seconds
}| Usage | Name | Description | Support |
|---|---|---|---|
| 0 | PKIX-TA | CA constraint, must chain to the specified CA | Partial |
| 1 | PKIX-EE | Service certificate constraint, must match exactly | Full |
| 2 | DANE-TA | Trust anchor assertion, specified cert is trust anchor | Full* |
| 3 | DANE-EE | Domain-issued certificate, certificate must match | Full |
* DANE-TA and PKIX-TA need the issuer chain, which Node does not pass to checkServerIdentity. Supply it yourself as shown in Verification flow. Without a chain, only the end-entity certificate is checked and these records never match.
The pinned certificate must lie on a signature-verified path from the certificate the server presented. A certificate that merely appears in the chain is not accepted: the trust anchor a TLSA record pins is public, so otherwise anyone could staple the pinned CA onto a chain of their own.
PKIX-TA remains partial because it does not additionally require PKIX path validation to have succeeded (RFC 7671 Section 5.1). RFC 7672 Section 3.1 makes usages 0 and 1 inapplicable to SMTP in any case; prefer DANE-EE.
The two work together. DANE gives stronger guarantees where DNSSEC is configured, MTA-STS covers domains without it:
const connection = await mxConnect({
target: 'user@example.com',
mtaSts: {
enabled: true,
cache: mtaStsCache
},
dane: {
enabled: true
}
});
// Both MTA-STS and DANE checks are performedThe DANE module is exported for direct use:
const { dane } = require('mx-connect');
// Is native TLSA resolution available
console.log(dane.hasNativeResolveTlsa);
// Constants
console.log(dane.DANE_USAGE, dane.DANE_SELECTOR, dane.DANE_MATCHING_TYPE);
// Resolve TLSA records for a host and port
const records = await dane.resolveTlsaRecords('mail.example.com', 25, {});
// Verify a certificate against TLSA records, chain optional for DANE-TA
const result = dane.verifyCertAgainstTlsa(certificate, tlsaRecords, chain);EUPL v1.1 or newer