mx-connect
Version:
Establish TCP connection to a MX server with MTA-STS and DANE/TLSA support
213 lines (197 loc) • 8.32 kB
JavaScript
/**
* @fileoverview Resolves Mail Exchange (MX) records for a domain.
* Implements a fallback chain: MX records -> A records -> AAAA records.
* This allows direct delivery to hosts without MX records configured.
* @module resolve-mx
*/
;
const net = require('net');
const dnsErrors = require('./dns-errors');
const tools = require('./tools');
/**
* Creates a standardized DNS error with category and response properties.
*
* Marks errors as temporary if they indicate a server-side DNS failure
* (like SERVFAIL) rather than a permanent "not found" condition.
*
* @param {Error|null} err - The original DNS error, or null to create a generic error
* @param {string} domain - The domain being resolved (for error message)
* @param {string} [defaultMessage='No MX server found'] - Message when err is null
* @returns {Error} Standardized error with message, response, code, category, and temporary properties
* @private
*/
function createDnsError(err, domain, defaultMessage) {
const error = err || new Error(defaultMessage || 'No MX server found');
const errorDescription = dnsErrors[error.code] || error.message;
error.message = `DNS error occurred while resolving the Mail Exchange (MX) server for the specified domain (${domain}). ${errorDescription}`;
error.response = `DNS Error: ${error.message}`;
error.code = error.code || 'ENOTFOUND';
error.category = 'dns';
// DNS server failures (SERVFAIL, etc.) are temporary; "not found" is permanent
if (err && !tools.isNotFoundError(err)) {
error.temporary = true;
}
return error;
}
/**
* Determines if a DNS error is recoverable (safe to try fallback record types).
*
* Returns true if:
* - No error occurred (empty result, not an error)
* - Error is ENODATA or ENOTFOUND (no records of this type exist)
*
* Returns false for actual DNS failures (SERVFAIL, REFUSED, etc.)
* which should be thrown immediately rather than masked by fallback attempts.
*
* @param {Error|null} err - The DNS error to check
* @returns {boolean} True if we should continue to fallback record types
* @private
*/
function isRecoverableError(err) {
return !err || tools.isNotFoundError(err);
}
/**
* Wraps a DNS resolution call with error handling.
*
* Returns both the result list and any error, allowing the caller to decide
* whether to throw, retry with fallback, or continue. This enables the
* MX -> A -> AAAA fallback chain without nested try/catch blocks.
*
* @param {Function} dnsResolve - Promise-based DNS resolver function
* @param {string} domain - Domain to resolve
* @param {string} [type] - Record type (MX, AAAA). Omit for A records.
* @returns {Promise<{list: Array, error: Error|null}>} Resolution result with list and error
* @private
*/
async function tryResolve(dnsResolve, domain, type) {
try {
const list = type !== undefined ? await dnsResolve(domain, type) : await dnsResolve(domain);
return { list: list || [], error: null };
} catch (err) {
return { list: [], error: err };
}
}
/**
* Resolves MX records for a domain with fallback to A/AAAA records.
*
* Resolution strategy:
* 1. If target is an IP address, use it directly (no DNS lookup)
* 2. Try MX records first - these are the canonical mail server entries
* 3. Fallback to A records - allows delivery to hosts without MX configured
* 4. Fallback to AAAA records - IPv6-only hosts (unless ignoreIPv6=true)
*
* The fallback behavior follows RFC 5321 Section 5.1, which specifies that
* if no MX records are found, the domain itself should be used as the mail host.
*
* @param {Object} delivery - The delivery object with parsed address info
* @param {string} delivery.decodedDomain - The ASCII domain to resolve
* @param {boolean} delivery.isIp - True if target is already an IP address
* @param {Object} [delivery.dnsOptions] - DNS configuration options
* @param {boolean} [delivery.dnsOptions.ignoreIPv6=false] - Skip AAAA record lookup
* @param {Function} [delivery.dnsOptions.resolve] - Custom DNS resolver
* @returns {Promise<Object>} Delivery object with populated mx array
* @throws {Error} If no valid MX servers can be resolved (error.category = 'dns')
*/
async function resolveMX(delivery) {
// Track first error and whether any valid address was found
// Used to provide meaningful errors when all addresses are filtered out
let firstError = null;
let addressFound = false;
const dnsOptions = delivery.dnsOptions || { ignoreIPv6: false };
/**
* Filters IP addresses, rejecting invalid/local ones.
* Captures the first error for later if all addresses are filtered out.
* Sets addressFound=true when a valid address passes through.
* @private
*/
const filterAddress = ip => {
const invalid = tools.isInvalid(delivery, ip);
if (invalid) {
if (!firstError) {
const detail = typeof invalid === 'string' ? `. ${invalid}` : '';
firstError = new Error(
`Unable to deliver email to the IP address [${ip}] resolved for the Mail Exchange (MX) server of "${delivery.domain}"${detail}`
);
firstError.response = `DNS Error: ${firstError.message}`;
firstError.category = 'dns';
}
return false;
}
addressFound = true;
return true;
};
// Handle IP address targets directly
if (delivery.isIp) {
if (!filterAddress(delivery.decodedDomain) && firstError) {
throw firstError;
}
const ip = delivery.decodedDomain;
delivery.mx = [
{
priority: 0,
exchange: ip,
A: net.isIPv4(ip) ? [ip] : [],
AAAA: net.isIPv6(ip) && !dnsOptions.ignoreIPv6 ? [ip] : []
}
];
return delivery;
}
const domain = delivery.decodedDomain;
const dnsResolve = tools.getDnsResolver(dnsOptions.resolve);
// Step 1: Try MX records (canonical mail server entries)
const mxResult = await tryResolve(dnsResolve, domain, 'MX');
if (mxResult.list.length) {
// Sort by priority (lower number = higher priority) per RFC 5321
delivery.mx = mxResult.list
.slice()
.sort((a, b) => a.priority - b.priority)
.map(entry => ({ ...entry, mx: true, A: [], AAAA: [] }));
return delivery;
}
// Non-recoverable DNS errors (SERVFAIL, etc.) should be thrown immediately
if (mxResult.error && !isRecoverableError(mxResult.error)) {
throw createDnsError(mxResult.error, domain);
}
// Step 2: Fallback to A records (RFC 5321 Section 5.1 implicit MX)
const aResult = await tryResolve(dnsResolve, domain);
if (aResult.list.length) {
delivery.mx = aResult.list.map(entry => ({
priority: 0,
exchange: domain,
mx: false, // Mark as implicit MX (not from MX record)
A: [entry].filter(filterAddress),
AAAA: []
}));
// If all addresses were filtered out as invalid, throw the captured error
if (!addressFound && firstError) {
throw firstError;
}
return delivery;
}
if (aResult.error && !isRecoverableError(aResult.error)) {
throw createDnsError(aResult.error, domain);
}
// Step 3: Fallback to AAAA records (IPv6-only hosts)
if (!dnsOptions.ignoreIPv6) {
const aaaaResult = await tryResolve(dnsResolve, domain, 'AAAA');
if (aaaaResult.list.length) {
delivery.mx = aaaaResult.list.map(entry => ({
priority: 0,
exchange: domain,
mx: false,
A: [],
AAAA: [entry].filter(filterAddress)
}));
if (!addressFound && firstError) {
throw firstError;
}
return delivery;
}
if (aaaaResult.error && !isRecoverableError(aaaaResult.error)) {
throw createDnsError(aaaaResult.error, domain);
}
}
// No records found at all - domain has no mail handling capability
throw createDnsError(null, domain, 'No MX server found');
}
module.exports = resolveMX;