UNPKG

haraka-plugin-qmail-deliverable

Version:

Haraka plugin that validates recipients against Qmail::Deliverable

333 lines (279 loc) 9.91 kB
// 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 }