haraka/haraka-plugin-qmail-deliverable

View on GitHub
index.js

Summary

Maintainability
C
7 hrs
Test Coverage
// validate an email address is local, using qmail-deliverabled

const http = require('http')
const querystring = require('querystring')
const url = require('url')

let outbound

exports.register = function () {
  this.load_qmd_ini()

  if (process.env.HARAKA) {
    // permit testing outside of Haraka
    outbound = this.haraka_require('outbound')
  }

  if (this.cfg.main.check_mail_from) {
    this.register_hook('mail', 'check_mail_from')
  }
}

exports.load_qmd_ini = function () {
  this.cfg = this.config.get(
    'qmail-deliverable.ini',
    {
      booleans: ['+main.check_mail_from', '*.check_mail_from'],
    },
    () => {
      this.load_qmd_ini()
    },
  )
}

exports.check_mail_from = function (next, connection, params) {
  if (!this.cfg.main.check_mail_from) return next()

  // determine if MAIL FROM domain is local
  const txn = connection.transaction

  const email = params[0].address()
  if (!email) {
    // likely an IP with relaying permission
    txn.results.add(this, { skip: 'mail_from.null', emit: true })
    return next()
  }

  const domain = params[0].host.toLowerCase()

  this.get_qmd_response(connection, params[0], (err, qmd_r) => {
    if (err) {
      txn.results.add(this, { err })
      return next(DENYSOFT, err)
    }

    // the MAIL FROM sender is verified as a local address
    if (qmd_r[0] === OK) {
      txn.results.add(this, { pass: `mail_from.${qmd_r[1]}` })
      txn.notes.local_sender = domain
      return next()
    }

    if (qmd_r[0] === undefined) {
      txn.results.add(this, { err: `mail_from.${qmd_r[1]}` })
      return next()
    }

    txn.results.add(this, { msg: `mail_from.${qmd_r[1]}` })
    next(CONT, `mail_from.${qmd_r[1]}`)
  })
}

function do_relaying(plugin, txn, next) {
  // any RCPT is acceptable for txns with relaying privileges
  // this is called in several places where errors or non-local rcpt would
  // otherwise not be allowed
  txn.results.add(plugin, {
    pass: `relaying${txn.notes.local_sender ? ' local sender' : ''}`,
  })
  txn.notes.set('queue.wants', 'outbound')
  next(OK)
}

exports.hook_rcpt = function (next, connection, params) {
  const txn = connection.transaction

  const rcpt = params[0]

  // Qmail::Deliverable::Client does a rfc2822 "atext" test
  // but Haraka has already validated for us
  this.get_qmd_response(connection, rcpt, (err, qmd_res) => {
    if (err) {
      if (connection.relaying) return do_relaying(this, txn, next)
      txn.results.add(this, { err })
      return next(DENYSOFT, 'error validating email address')
    }
    this.do_qmd_response(qmd_res, connection, rcpt, next)
  })
}

exports.do_qmd_response = function (qmd_res, connection, rcpt, next) {
  const txn = connection.transaction

  const [r_code, dst_type] = qmd_res

  if (r_code === undefined) {
    if (connection.relaying) return do_relaying(this, txn, next)
    txn.results.add(this, { err: `rcpt.${dst_type}` })
    return next()
  }

  if (r_code !== OK) {
    if (connection.relaying) return do_relaying(this, txn, next)
    // no need to DENY[SOFT] for invalid addresses. If no rcpt_to.* plugin
    // returns OK, then the address is not accepted.
    txn.results.add(this, { msg: `rcpt.${dst_type}` })
    return next(CONT, dst_type)
  }

  const domain = rcpt.host.toLowerCase()
  const dom_cfg = this.cfg[domain] || this.cfg.main

  txn.notes.local_recipient = domain
  txn.results.add(this, { pass: `rcpt.${dst_type}` })

  let queue = this.get_queue(domain)
  let next_hop = this.get_next_hop(domain, queue)

  if (dst_type === 'vpopmail dir' && next_hop) {
    if (/^lmtp/.test(next_hop)) queue = 'lmtp'
    next_hop = this.get_next_hop(domain, queue)
  }

  if (this.is_split(txn, queue, next_hop)) {
    if (dom_cfg?.split === 'defer') {
      if (connection.relaying) return do_relaying(this, txn, next)
      return next(DENYSOFT, 'Split transaction, retry soon')
    }
    txn.results.add(this, { msg: `split queue.wants=outbound`, emit: true })
    txn.notes.set('queue.wants', 'outbound')
    delete txn.notes?.queue?.next_hop
  } else {
    if (!txn.notes.get('queue.wants')) {
      txn.results.add(this, {
        msg: `queue.wants=${queue}, next_hop=${next_hop}`,
        emit: true,
      })
      txn.notes.set('queue.wants', queue)
      txn.notes.set('queue.next_hop', next_hop)
    }
  }

  next(OK)
}

exports.is_split = function (txn, queue, next_hop) {
  if (txn.rcpt_to.length > 1) {
    const qw = txn.notes.get('queue.wants')
    if (qw && qw !== queue) return true

    const qnh = txn.notes.get('queue.next_hop')
    if (qnh && qnh !== next_hop) return true
  }

  return false // identical destinations
}

exports.get_next_hop = function (domain, queue) {
  const hop = this.cfg[domain]?.next_hop || this.cfg.main.next_hop
  if (hop) return hop
  return `${queue === 'lmtp' ? 'lmtp' : 'smtp'}://${this.get_host(domain)}`
}

exports.get_queue = function (domain) {
  // lmtp, outbound, smtp_forward, qmail-queue
  return this.cfg[domain]?.queue || this.cfg.main.queue
}

exports.get_host = function (domain) {
  return this.cfg[domain]?.host || this.cfg.main.host || '127.0.0.1'
}

exports.get_port = function (domain) {
  return this.cfg[domain]?.port || this.cfg.main.port || 8998
}

exports.get_qmd_response = function (connection, addr, cb) {
  const plugin = this

  const domain = addr.host.toLowerCase()
  const email = addr.address()

  const options = {
    method: 'get',
    host: plugin.get_host(domain),
    port: plugin.get_port(domain),
  }

  connection.logdebug(plugin, `checking ${email}`)
  options.path = `/qd1/deliverable?${querystring.escape(email)}`
  // connection.logdebug(plugin, 'PATH: ' + options.path);
  http
    .get(options, function (res) {
      connection.logprotocol(plugin, `STATUS: ${res.statusCode}`)
      connection.logprotocol(plugin, `HEADERS: ${JSON.stringify(res.headers)}`)
      res.setEncoding('utf8')
      res.on('data', function (chunk) {
        connection.logprotocol(plugin, `BODY: ${chunk}`)
        const hexnum = new Number(chunk).toString(16)
        const arr = plugin.decode_qmd_response(connection, hexnum)
        connection.logdebug(plugin, arr[1])
        cb(undefined, arr)
      })
    })
    .on('error', cb)
}

exports.decode_qmd_response = function (connection, hexnum) {
  connection.logprotocol(this, `HEXRV: ${hexnum}`)

  switch (hexnum) {
    case '11':
      return [DENYSOFT, 'permission failure']
    case '12':
      return [OK, 'qmail-command in dot-qmail']
    case '13':
      return [OK, 'bouncesaying with program']
    case '14': {
      const from = connection.transaction.mail_from.address()
      if (!from || from === '<>') {
        return [DENY, 'mailing lists do not accept null senders']
      }
      return [OK, 'ezmlm list']
    }
    case '21':
      return [DENYSOFT, 'Temporarily undeliverable: group/world writable']
    case '22':
      return [DENYSOFT, 'Temporarily undeliverable: sticky home directory']
    case '2f':
      return [DENYSOFT, 'error communicating with qmail-deliverabled.']
    case 'f1':
      return [OK, 'normal delivery']
    case 'f2':
      return [OK, 'vpopmail dir']
    case 'f3':
      return [OK, 'vpopmail alias']
    case 'f4':
      return [OK, 'vpopmail catchall']
    case 'f5':
      return [OK, 'vpopmail vuser']
    case 'f6':
      return [OK, 'vpopmail qmail-ext']
    case 'fe':
      return [DENYSOFT, 'SHOULD NOT HAPPEN']
    case 'ff':
      return [DENY, 'not local']
    case '0':
      return [DENY, 'not deliverable']
    default:
      return [undefined, `unknown rv(${hexnum})`]
  }
}

exports.hook_queue = function (next, connection) {
  const qw = connection.transaction.notes.get('queue.wants')
  switch (qw) {
    case 'lmtp':
    case 'outbound':
      this.logdebug(`routing to outbound: queue.wants=${qw}`)
      outbound.send_trans_email(connection.transaction, next)
      break
    default:
      next() // do nothing
  }
}

exports.hook_get_mx = function (next, hmail, domain) {
  if (hmail.todo.notes.get('queue.wants') !== 'lmtp') return next()

  const mx = {
    using_lmtp: true,
    priority: 0,
    port: 24,
    exchange: this.get_host(domain.toLowerCase()),
  }

  const nh = hmail.todo.notes.get('queue.next_hop')
  if (nh) {
    const dest = new url.URL(nh)
    if (dest.hostname) mx.exchange = dest.hostname
    if (dest.port) mx.port = dest.port
    if (dest.auth) {
      mx.auth_type = 'plain'
      mx.auth_user = dest.auth.split(':')[0]
      mx.auth_pass = dest.auth.split(':')[1]
    }
  }

  this.logdebug(mx)
  next(OK, mx)
}