haraka-plugin-qmail-deliverable
Version:
Haraka plugin that validates recipients against Qmail::Deliverable
333 lines (279 loc) • 9.91 kB
JavaScript
// validate an email address is local, using qmail-deliverabled
const url = require('node:url')
let outbound
exports.register = function () {
this.load_qmd_ini()
if (process.env.HARAKA) {
// permit testing outside of Haraka
outbound = this.haraka_require('outbound')
} else {
outbound = undefined
}
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 = async function (next, connection, params) {
if (!this.cfg.main.check_mail_from) return next()
// determine if MAIL FROM domain is local
const email = params[0]?.address
if (!email) {
// likely an IP with relaying permission
results(connection).add(this, { skip: 'mail_from.null', emit: true })
return next()
}
try {
const qmd_r = await this.get_qmd_response(connection, params[0])
if (!qmd_r) {
results(connection).add(this, { err: 'mail_from.qmd_unavailable' })
return next()
}
// the MAIL FROM sender is verified as a local address
if (qmd_r[0] === OK) {
results(connection).add(this, { pass: `mail_from.${qmd_r[1]}` })
const domain = params[0]?.host?.toLowerCase()
if (domain && connection.transaction?.notes) {
connection.transaction.notes.local_sender = domain
}
return next()
}
if (qmd_r[0] === undefined) {
results(connection).add(this, { err: `mail_from.${qmd_r[1]}` })
return next()
}
results(connection).add(this, { msg: `mail_from.${qmd_r[1]}` })
next(CONT, `mail_from.${qmd_r[1]}`)
} catch (err) {
results(connection).add(this, { err: err.message })
next(DENYSOFT, err?.message ? err.message : String(err))
}
}
function do_relaying(plugin, connection, next) {
if (!connection.transaction) return 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
const txn = connection.transaction
results(connection).add(plugin, {
pass: `relaying${txn?.notes?.local_sender ? ' local sender' : ''}`,
})
if (txn?.notes) txn.notes.set('queue.wants', 'outbound')
next(OK)
}
exports.hook_rcpt = async function (next, connection, params) {
try {
const rcpt = params[0]
const qmd_res = await this.get_qmd_response(connection, rcpt)
this.do_qmd_response(qmd_res, connection, rcpt, next)
} catch (err) {
if (connection.relaying) return do_relaying(this, connection, next)
const results = connection.transaction ? connection.transaction.results : connection.results
results.add(this, { err: err.message })
next(DENYSOFT, 'error validating email address')
}
}
exports.do_qmd_response = function (qmd_res, connection, rcpt, next) {
const txn = connection.transaction
if (!txn) return next()
const [r_code, dst_type] = qmd_res
if (r_code === undefined) {
if (connection.relaying) return do_relaying(this, connection, next)
results(connection).add(this, { err: `rcpt.${dst_type}` })
return next()
}
if (r_code !== OK) {
if (connection.relaying) return do_relaying(this, connection, next)
// no need to DENY[SOFT] for invalid addresses. If no rcpt_to.* plugin
// returns OK, then the address is not accepted.
results(connection).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
if (domain && txn?.notes) txn.notes.local_recipient = domain
results(connection).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, connection, next)
return next(DENYSOFT, 'Split transaction, retry soon')
}
results(connection).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')) {
results(connection).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 = async function (connection, addr) {
const domain = addr.host.toLowerCase()
const email = addr.address
const fetch_url = `http://${this.get_host(domain)}:${this.get_port(domain)}/qd1/deliverable?${encodeURIComponent(email)}`
connection.logdebug(this, `checking ${email}`)
const fetch_impl = this.fetch || globalThis.fetch
if (!fetch_impl) throw new Error('fetch is unavailable')
const timeout = (this.cfg[domain]?.timeout ?? this.cfg.main.timeout ?? 27) * 1000
let response
try {
response = await fetch_impl(fetch_url, {
method: 'GET',
headers: { Connection: 'close' },
signal: AbortSignal.timeout(timeout),
})
} catch (err) {
connection.logerror(this, `error fetching qmd for ${email}: ${err}`)
return
}
const headers = response.headers?.entries
? Object.fromEntries(response.headers.entries())
: response.headers
connection.logprotocol(this, `STATUS: ${response.status}`)
connection.logprotocol(this, `HEADERS: ${JSON.stringify(headers)}`)
if (!response.ok) {
connection.logerror(this, `qmd returned HTTP ${response.status} for ${email}`)
return
}
const body = await response.text()
connection.logprotocol(this, `BODY: ${body}`)
// ensure body contains a number; fallback to unknown response handling
const num = Number(String(body || '').trim())
if (Number.isNaN(num)) {
connection.logerror(this, `qmd returned invalid body for ${email}: ${body}`)
return
}
return this.decode_qmd_response(connection, num.toString(16))
}
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) {
if (!connection.transaction) return next()
const qw = connection.transaction.notes.get('queue.wants')
switch (qw) {
case 'lmtp':
case 'outbound':
if (!outbound?.send_trans_email) {
this.logerror('outbound is unavailable')
return next(DENYSOFT, 'outbound is unavailable')
}
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.username || dest.password) {
mx.auth_type = 'plain'
mx.auth_user = decodeURIComponent(dest.username)
mx.auth_pass = decodeURIComponent(dest.password)
}
}
this.logdebug(mx)
next(OK, mx)
}
function results(connection) {
return connection.transaction ? connection.transaction.results : connection.results
}