haraka/haraka-net-utils

View on GitHub
lib/get_mx.js

Summary

Maintainability
A
1 hr
Test Coverage
'use strict'

const { Resolver } = require('node:dns').promises
const dns = new Resolver({ timeout: 25000, tries: 1 })
const net = require('node:net')

const punycode = require('punycode.js')

const HarakaMx = require('./HarakaMx')

exports.get_mx = async (raw_domain, cb) => {
  const domain = normalizeDomain(raw_domain)

  try {
    let exchanges = await dns.resolveMx(domain)
    if (exchanges && exchanges.length) {
      exchanges = exchanges.map((e) => new HarakaMx(e, domain))
      if (cb) return cb(null, exchanges)
      return exchanges
    }
    // no MX record(s), fall through
  } catch (err) {
    if (fatal_mx_err(err)) {
      if (cb) return cb(err, [])
      throw err
    }
    // non-terminal DNS failure, fall through
  }

  const exchanges = await this.get_implicit_mx(domain)
  if (cb) return cb(null, exchanges)
  return exchanges
}

exports.get_implicit_mx = async (domain) => {
  // console.log(`No MX for ${domain}, trying AAAA & A records`)

  const r = await Promise.allSettled([
    dns.resolve6(domain),
    dns.resolve4(domain),
  ])

  return r
    .filter((a) => a.status === 'fulfilled')
    .flatMap((a) => a.value.map((ip) => new HarakaMx(ip, domain)))
}

exports.resolve_mx_hosts = async (mxes) => {
  // for the given list of MX exchanges, resolve the hostnames to IPs
  const promises = []

  for (const mx of mxes) {
    if (!mx.exchange) {
      // console.error(`MX without an exchange. could be a socket`)
      promises.push(mx)
      continue
    }

    if (net.isIP(mx.exchange)) {
      promises.push(mx) // already resolved
      continue
    }

    // resolve AAAA and A since mx.exchange is a hostname
    promises.push(
      dns
        .resolve6(mx.exchange)
        .then((ips) =>
          ips.map((ip) => ({ ...mx, exchange: ip, from_dns: mx.exchange })),
        ),
    )

    promises.push(
      dns
        .resolve4(mx.exchange)
        .then((ips) =>
          ips.map((ip) => ({ ...mx, exchange: ip, from_dns: mx.exchange })),
        ),
    )
  }

  const settled = await Promise.allSettled(promises)

  return settled.filter((s) => s.status === 'fulfilled').flatMap((s) => s.value)
}

function normalizeDomain(raw_domain) {
  let domain = raw_domain

  if (/@/.test(domain)) {
    domain = domain.split('@').pop()
    // console.log(`\treduced ${raw_domain} to ${domain}.`)
  }

  if (/^xn--/.test(domain)) {
    // is punycode IDN with ACE, ASCII Compatible Encoding
  } else if (domain !== punycode.toASCII(domain)) {
    domain = punycode.toASCII(domain)
    console.log(`\tACE encoded '${raw_domain}' to '${domain}'`)
  }

  return domain
}

function fatal_mx_err(err) {
  // Possible DNS errors
  // NODATA
  // FORMERR
  // BADRESP
  // NOTFOUND
  // BADNAME
  // TIMEOUT
  // CONNREFUSED
  // NOMEM
  // DESTRUCTION
  // NOTIMP
  // EREFUSED
  // SERVFAIL

  switch (err.code) {
    case 'ENODATA':
    case 'ENOTFOUND':
      // likely a hostname with no MX record, drop through
      return false
    default:
      return err
  }
}