'use strict' // a single connection const dns = require('node:dns') const os = require('node:os') // npm libs const ipaddr = require('ipaddr.js') const config = require('haraka-config') const constants = require('haraka-constants') const net_utils = require('haraka-net-utils') const Notes = require('haraka-notes') const utils = require('haraka-utils') const { Address } = require('@haraka/email-address') const ResultStore = require('haraka-results') // Haraka libs const logger = require('./logger') const trans = require('./transaction') const plugins = require('./plugins') const rfc1869 = utils.rfc1869 const outbound = require('./outbound') const states = constants.connection.state const version = utils.getVersion(__dirname) const MAX_POST_DATA_BYTES = 8192 const cfg = config.get('connection.ini', { booleans: [ '-main.strict_rfc1869', '-main.postel', '+main.smtputf8', '+headers.add_received', '+headers.show_version', '+headers.clean_auth_results', ], }) // Count LF-terminated commands in the post-DATA buffer, which process_data() has // already bounded to MAX_POST_DATA_BYTES. Every complete command is counted so // respond() can answer each with a 503 and keep the client's response count in sync. function count_complete_commands(buf) { let count = 0 let rest = buf let offset while ((offset = utils.indexOfLF(rest)) !== -1) { count++ rest = rest.slice(offset + 1) } return count } class Connection { constructor(client, server) { this.client = client this.server = server this.local = { ip: null, port: null, host: net_utils.get_primary_host_name(), info: 'Haraka', } this.remote = { ip: null, port: null, host: null, info: null, closed: false, is_private: false, is_local: false, } this.hello = { host: null, verb: null, } this.tls = { enabled: false, advertised: false, verified: false, cipher: {}, } this.proxy = { allowed: false, ip: null, type: null, timer: null, } this.set('tls', 'enabled', !!server.has_tls) this.current_data = null this.current_line = null this.state = states.PAUSE this.encoding = 'utf8' this.prev_state = null this.awaiting_data_reply = false this.loop_code = null this.loop_msg = null this.uuid = utils.uuid() this.notes = new Notes() this.transaction = null this.tran_count = 0 this.capabilities = null this.early_talker = false this.pipelining = false this._relaying = false this.esmtp = false this.last_response = null this.hooks_to_run = [] this.start_time = Date.now() this.last_reject = '' this.totalbytes = 0 this.rcpt_count = { accept: 0, tempfail: 0, reject: 0, } this.msg_count = { accept: 0, tempfail: 0, reject: 0, } this.results = new ResultStore(this) this.errors = 0 this.last_rcpt_msg = null this.hook = null if (cfg.headers.show_version) { this.local.info += `/${version}` } Connection.setupClient(this) } static setupClient(self) { const ip = self.client.remoteAddress if (!ip) { self.logdebug('setupClient got no IP address for this connection!') self.client.destroy() return } const local_addr = self.server.address() self.set('local', 'ip', ipaddr.process(self.client.localAddress || local_addr.address).toString()) self.set('local', 'port', self.client.localPort || local_addr.port) self.results.add({ name: 'local' }, self.local) self.set('remote', 'ip', ipaddr.process(ip).toString()) self.set('remote', 'port', self.client.remotePort) self.results.add({ name: 'remote' }, self.remote) self.lognotice('connect', { ip: self.remote.ip, port: self.remote.port, local_ip: self.local.ip, local_port: self.local.port, }) if (!self.client.on) return const log_data = { ip: self.remote.ip } if (self.remote.host) log_data.host = self.remote.host self.client.on('end', () => { if (self.state >= states.DISCONNECTING) return self.remote.closed = true self.loginfo('client half closed connection', log_data) self.fail() }) self.client.on('close', () => { if (self.state >= states.DISCONNECTING) return self.remote.closed = true self.loginfo('client dropped connection', log_data) self.fail() }) self.client.on('error', (err) => { if (self.state >= states.DISCONNECTING) return self.loginfo(`client connection error: ${err}`, log_data) self.fail() }) self.client.on('timeout', () => { // FIN has sent, when timeout just destroy socket if (self.state >= states.DISCONNECTED) { self.client.destroy() self.loginfo(`timeout, destroy socket (state:${self.state})`) return } if (self.state >= states.DISCONNECTING) return self.respond(421, 'timeout', () => { self.fail('client connection timed out', log_data) }) }) self.client.on('data', (data) => { self.process_data(data) }) // SMTPS pre-parser state: proxy means the PROXY line was already consumed; // peer_allowed means a trusted PROXY peer sent direct TLS instead. const smtps = self.client.haraka_smtps if (smtps?.proxy) { self.proxy.allowed = true return } if (smtps?.peer_allowed) { plugins.run_hooks('connect_init', self) return } if (net_utils.is_haproxy_allowed(self.remote.ip)) { self.proxy.allowed = true // Wait for PROXY command self.proxy.timer = setTimeout(() => { self.respond(421, 'PROXY timeout', () => { self.disconnect() }) }, 30 * 1000) } else { plugins.run_hooks('connect_init', self) } } setTLS(obj) { this.set('hello', 'host', undefined) this.set('tls', 'enabled', true) for (const t of ['cipher', 'verified', 'verifyError', 'peerCertificate']) { if (obj[t] === undefined) continue this.set('tls', t, obj[t]) } // prior to 2017-07, authorized and verified were both used. Verified // seems to be the more common and has the property updated in the // tls object. However, authorized has been up-to-date in the notes. Store // in both, for backwards compatibility. this.notes.tls = { authorized: obj.verified, // legacy name authorizationError: obj.verifyError, cipher: obj.cipher, peerCertificate: obj.peerCertificate, } } set(prop_str, val) { if (arguments.length === 3) { prop_str = `${arguments[0]}.${arguments[1]}` val = arguments[2] } const path_parts = prop_str.split('.') let loc = this for (let i = 0; i < path_parts.length; i++) { const part = path_parts[i] if (part === '__proto__' || part === 'constructor') continue // while another part remains if (i < path_parts.length - 1) { if (loc[part] === undefined) loc[part] = {} // initialize loc = loc[part] // descend continue } // last part, so assign the value loc[part] = val } // Set is_private, is_local automatically when remote.ip is set if (prop_str === 'remote.ip') { this.set('remote.is_local', net_utils.is_local_ip(this.remote.ip)) if (this.remote.is_local) { this.set('remote.is_private', true) } else { this.set('remote.is_private', net_utils.is_private_ip(this.remote.ip)) } } } get(prop_str) { return prop_str.split('.').reduce((prev, curr) => { return prev ? prev[curr] : undefined }, this) } set relaying(val) { if (this.transaction) { this.transaction._relaying = val } else { this._relaying = val } } get relaying() { if (this.transaction && '_relaying' in this.transaction) return this.transaction._relaying return this._relaying } process_line(line) { if (this.state >= states.DISCONNECTING) { if (logger.would_log(logger.LOGPROTOCOL)) { this.logprotocol(`C: (after-disconnect): ${this.current_line}`, { state: this.state, }) } this.loginfo(`data after disconnect from ${this.remote.ip}`) return } if (this.state === states.DATA) { if (logger.would_log(logger.LOGDATA)) { this.logdata(`C: ${line}`) } this.accumulate_data(line) return } this.current_line = line.toString(this.encoding).replace(/\r?\n/, '') if (logger.would_log(logger.LOGPROTOCOL)) { this.logprotocol(`C: ${this.current_line}`, { state: this.state }) } // Check for non-ASCII characters /* eslint no-control-regex: 0 */ if (/[^\x00-\x7F]/.test(this.current_line)) { // See if this is a TLS handshake const buf = Buffer.from(this.current_line.slice(0, 3), 'binary') if ( buf[0] === 0x16 && buf[1] === 0x03 && (buf[2] === 0x00 || buf[2] === 0x01) // SSLv3/TLS1.x format ) { // Nuke the current input buffer to prevent processing further input this.current_data = null this.respond(501, 'SSL attempted over a non-SSL socket') this.disconnect() return } else if (this.hello.verb == 'HELO') { return this.respond(501, 'Syntax error (8-bit characters not allowed)') } } if (this.state === states.CMD) { this.state = states.PAUSE_SMTP const matches = /^([^ ]*)( +(.*))?$/.exec(this.current_line) if (!matches) { return plugins.run_hooks('unrecognized_command', this, [this.current_line]) } const cmd = matches[1] const method = `cmd_${cmd.toLowerCase()}` const remaining = matches[3] || '' if (this[method]) { try { this[method](remaining) } catch (err) { this.logerror(`${method} failed:`, err) this.respond(421, 'Internal Server Error', () => { this.disconnect() }) } } else { // unrecognized command plugins.run_hooks('unrecognized_command', this, [cmd, remaining]) } } else if (this.state === states.LOOP) { // Allow QUIT if (this.current_line.toUpperCase() === 'QUIT') { this.state = states.PAUSE_SMTP this.cmd_quit() } else { this.respond(this.loop_code, this.loop_msg) } } else if (this.state === states.PAUSE_SMTP) { // Do nothing } else { throw new Error(`unknown state ${this.state}`) } } process_data(data) { if (this.state >= states.DISCONNECTING) { this.loginfo(`data after disconnect from ${this.remote.ip}`) return } if (!this.current_data || !this.current_data.length) { this.current_data = data } else { // Data left over in buffer const buf = Buffer.concat([this.current_data, data], this.current_data.length + data.length) this.current_data = buf } this._process_data() // Checked after processing so it also covers the read that carries the end-of-DATA // terminator and a pipelined command together. A compliant client sends nothing in // the soft-paused post-DATA window; a flood there is a pipelining attack. if (this.awaiting_data_reply && this.current_data?.length > MAX_POST_DATA_BYTES) { this.loginfo(`dropping ${this.current_data.length}B pipelined after DATA from ${this.remote.ip}`) this.current_data = null this.disconnect() } } _process_data() { // We *must* detect disconnected connections here as the state // only transitions to states.CMD in the respond function below. // Otherwise if multiple commands are pipelined and then the // connection is dropped; we'll end up in the function forever. if (this.state >= states.DISCONNECTING) return let maxlength if (this.state === states.PAUSE_DATA || this.state === states.DATA) { maxlength = cfg.max.data_line_length } else { maxlength = cfg.max.line_length } let offset while (this.current_data && (offset = utils.indexOfLF(this.current_data, maxlength)) !== -1) { if (this.state === states.PAUSE_DATA) { return } let this_line = this.current_data.slice(0, offset + 1) // Hack: bypass this code to allow HAProxy's PROXY extension const proxyStart = this.proxy.allowed && /^PROXY /.test(this_line) if (this.state === states.PAUSE && proxyStart) { this._clear_proxy_timer() this.state = states.CMD this.current_data = this.current_data.slice(this_line.length) this.process_line(this_line) } // Detect early_talker but allow PIPELINING extension (ESMTP) else if ((this.state === states.PAUSE || this.state === states.PAUSE_SMTP) && !this.esmtp) { // Allow EHLO/HELO to be pipelined with PROXY if (this.proxy.allowed && /^(?:EH|HE)LO /i.test(this_line)) return if (!this.early_talker) { this_line = this_line.toString().replace(/\r?\n/, '') this.logdebug('[early_talker]', { state: this.state, esmtp: this.esmtp, line: this_line, }) } this.early_talker = true setImmediate(() => { this._process_data() }) break } else if ((this.state === states.PAUSE || this.state === states.PAUSE_SMTP) && this.esmtp) { let valid = true const cmd = this_line.toString('ascii').slice(0, 4).toUpperCase() switch (cmd) { case 'RSET': case 'MAIL': case 'SEND': case 'SOML': case 'SAML': case 'RCPT': // These can be anywhere in the group break default: // Anything else *MUST* be the last command in the group if (this_line.length !== this.current_data.length) { valid = false } break } if (valid) { // Valid PIPELINING // We *don't want to process this yet otherwise the // current_data buffer will be lost. The respond() // function will call this function again once it // has reset the state back to states.CMD and this // ensures that we only process one command at a // time. this.pipelining = true this.logdebug(`pipeline: ${this_line}`) } else { // Invalid pipeline sequence // Treat this as early talker if (!this.early_talker) { this.logdebug('[early_talker]', { state: this.state, esmtp: this.esmtp, line: this_line, }) } this.early_talker = true setImmediate(() => { this._process_data() }) } break } else { this.current_data = this.current_data.slice(this_line.length) this.process_line(this_line) } } // Once end-of-DATA is seen the message is complete, so bytes still buffered are // pipelined commands, not body. Measuring them against data_line_length would // wrap them and flag the finished transaction; respond() judges them instead. if ( this.current_data && !this.awaiting_data_reply && this.current_data.length > maxlength && utils.indexOfLF(this.current_data, maxlength) === -1 ) { if (this.state !== states.DATA && this.state !== states.PAUSE_DATA) { // In command mode, reject: this.client.pause() this.current_data = null return this.respond(521, 'Command line too long', () => { this.disconnect() }) } else { this.loginfo(`DATA line length (${this.current_data.length}) exceeds limit of ${maxlength} bytes`) this.transaction.notes.data_line_length_exceeded = true const b = Buffer.concat( [ this.current_data.slice(0, maxlength - 2), Buffer.from('\r\n ', 'utf8'), this.current_data.slice(maxlength - 2), ], this.current_data.length + 3, ) this.current_data = b return this._process_data() } } } respond(code, msg, func) { let uuid = '' let messages if (this.state === states.DISCONNECTED) { if (func) func() return } // First reply after end-of-DATA. The socket was only soft-paused, so current_data // holds whatever the client pipelined before it saw this reply. If our end-of-DATA // detection disagreed with the sender's, those bytes are attacker-authored, so none // of them run (GHSA-rp4q-8m6x-c43c) -- not even the RSET/MAIL FROM that RFC 2920 // sec 3.1 would otherwise allow here. Each complete command is answered 503 below: // sec 3.1 has the client correlating responses by count, so silence desynchronises it. let refused_after_data = 0 if (this.awaiting_data_reply) { this.awaiting_data_reply = false if (this.current_data?.length) { refused_after_data = count_complete_commands(this.current_data) this.lognotice('command pipelined after end of DATA - refused', { remote: this.remote.ip, buffered: this.current_data.length, commands: refused_after_data, }) this.current_data = null } } // Check to see if DSN object was passed in if (typeof msg === 'object' && msg.constructor.name === 'DSN') { // Override code = msg.code msg = msg.reply } if (!Array.isArray(msg)) { messages = msg.toString().split(/\n/) } else { messages = msg.slice() } messages = messages.filter((msg2) => /\S/.test(msg2)) // Multiline AUTH PLAIN as in RFC-4954 page 8. if (code === 334 && !messages.length) { messages = [' '] } if (code >= 400) { this.last_reject = `${code} ${messages.join(' ')}` if (cfg.uuid.deny_chars) { uuid = (this.transaction || this).uuid if (cfg.uuid.deny_chars > 1) { uuid = uuid.slice(0, cfg.uuid.deny_chars) } } } let mess let buf = '' const hostname = os.hostname().split('.').shift() const _uuid = uuid ? `[${uuid}@${hostname}] ` : '' while ((mess = messages.shift())) { const line = `${code}${messages.length ? '-' : ' '}${_uuid}${mess}` this.logprotocol(`S: ${line}`) buf = `${buf}${line}\r\n` } for (let i = 0; i < refused_after_data; i++) { const line = '503 Bad sequence of commands - pipelined after end of DATA' this.logprotocol(`S: ${line}`) buf = `${buf}${line}\r\n` } if (this.client.write === undefined) return buf // testing try { this.client.write(buf) } catch (err) { return this.fail(`Writing response: ${buf} failed: ${err}`) } // Store the last response this.last_response = buf // Don't change loop state if (this.state !== states.LOOP) { this.state = states.CMD } // Run optional closure before handling and further commands if (func) func() // Process any buffered commands (PIPELINING). Deferred so that a long // pipeline does not recurse setImmediate(() => this._process_data()) } fail(err, err_data) { if (err) this.logwarn(err, err_data) this.hooks_to_run = [] this.disconnect() } disconnect() { if (this.state >= states.DISCONNECTING) return this.state = states.DISCONNECTING this.current_data = null // don't process any more data we have already received // the client can disconnect before sending its PROXY header this._clear_proxy_timer() this.reset_transaction(() => { plugins.run_hooks('disconnect', this) }) } disconnect_respond() { const logdetail = { ip: this.remote.ip, rdns: this.remote.host ? this.remote.host : '', helo: this.hello.host ? this.hello.host : '', relay: this.relaying ? 'Y' : 'N', early: this.early_talker ? 'Y' : 'N', esmtp: this.esmtp ? 'Y' : 'N', tls: this.tls.enabled ? 'Y' : 'N', pipe: this.pipelining ? 'Y' : 'N', errors: this.errors, txns: this.tran_count, rcpts: `${this.rcpt_count.accept}/${this.rcpt_count.tempfail}/${this.rcpt_count.reject}`, msgs: `${this.msg_count.accept}/${this.msg_count.tempfail}/${this.msg_count.reject}`, bytes: this.totalbytes, lr: this.last_reject ? this.last_reject : '', time: (Date.now() - this.start_time) / 1000, } this.results.add( { name: 'disconnect' }, { duration: (Date.now() - this.start_time) / 1000, }, ) this.lognotice('disconnect', logdetail) this.state = states.DISCONNECTED this.client.end() } get_capabilities() { return [] } tran_uuid() { this.tran_count++ return `${this.uuid}.${this.tran_count}` } reset_transaction(cb) { this.results.add( { name: 'reset' }, { duration: (Date.now() - this.start_time) / 1000, }, ) if (this.transaction && this.transaction.resetting === false) { // Pause connection to allow the hook to complete this.pause() this.transaction.resetting = true plugins.run_hooks('reset_transaction', this, cb) } else { this.transaction = null if (cb) cb() } } reset_transaction_respond(retval, msg, cb) { if (this.transaction) { this.transaction.message_stream.destroy() this.transaction = null } if (cb) cb() // Allow the connection to continue this.resume() } init_transaction(cb) { this.reset_transaction(() => { this.transaction = trans.createTransaction(this.tran_uuid(), cfg) // Catch any errors from the message_stream this.transaction.message_stream.on('error', (err) => { this.logcrit(`message_stream error: ${err.message}`) this.respond('421', 'Internal Server Error', () => { this.disconnect() }) }) this.transaction.results = new ResultStore(this) if (cb) cb() }) } loop_respond(code, msg) { if (this.state >= states.DISCONNECTING) return this.state = states.LOOP this.loop_code = code this.loop_msg = msg this.respond(code, msg) } pause() { if (this.state >= states.DISCONNECTING) return this.client.pause() if (this.state !== states.PAUSE_DATA) this.prev_state = this.state this.state = states.PAUSE_DATA } resume() { if (this.state >= states.DISCONNECTING) return this.client.resume() if (this.prev_state && this.state === states.PAUSE_DATA) { this.state = this.prev_state } this.prev_state = null setImmediate(() => this._process_data()) } ///////////////////////////////////////////////////////////////////////////// // SMTP Responses connect_init_respond() { this.logdebug('running connect_init_respond') plugins.run_hooks('lookup_rdns', this) } lookup_rdns_respond(retval, msg) { switch (retval) { case constants.ok: this.set('remote', 'host', utils.sanitize(msg) || 'Unknown') this.set('remote', 'info', this.remote.info || this.remote.host) plugins.run_hooks('connect', this) break case constants.deny: this.loop_respond(554, msg || 'rDNS Lookup Failed') break case constants.denydisconnect: case constants.disconnect: this.respond(554, msg || 'rDNS Lookup Failed', () => { this.disconnect() }) break case constants.denysoft: this.loop_respond(421, msg || 'rDNS Temporary Failure') break case constants.denysoftdisconnect: this.respond(421, msg || 'rDNS Temporary Failure', () => { this.disconnect() }) break default: // BUG: dns.reverse throws on invalid input (and sometimes valid // input nodejs/node#47847). Also throws when empty results try { dns.reverse(this.remote.ip, (err, domains) => { this.rdns_response(err, domains) }) } catch (err) { this.rdns_response(err, []) } } } rdns_response(err, domains) { if (err) { switch (err.code) { case dns.NXDOMAIN: case dns.NOTFOUND: this.set('remote', 'host', 'NXDOMAIN') break default: this.set('remote', 'host', 'DNSERROR') break } } else { this.set('remote', 'host', utils.sanitize(domains[0]) || 'Unknown') this.results.add({ name: 'remote' }, this.remote) } this.set('remote', 'info', this.remote.info || this.remote.host) plugins.run_hooks('connect', this) } unrecognized_command_respond(retval, msg) { switch (retval) { case constants.ok: // response already sent, cool... break case constants.next_hook: plugins.run_hooks(msg, this) break case constants.deny: this.respond(500, msg || 'Unrecognized command') break case constants.denydisconnect: case constants.denysoftdisconnect: this.respond(retval === constants.denydisconnect ? 521 : 421, msg || 'Unrecognized command', () => { this.disconnect() }) break default: this.errors++ this.respond(500, msg || 'Unrecognized command') } } connect_respond(retval, msg) { // RFC 5321 Section 4.3.2 states that the only valid SMTP codes here are: // 220 = Service ready // 554 = Transaction failed (no SMTP service here) // 421 = Service shutting down and closing transmission channel switch (retval) { case constants.deny: this.loop_respond(554, msg || 'Your mail is not welcome here') break case constants.denydisconnect: case constants.disconnect: this.respond(554, msg || 'Your mail is not welcome here', () => { this.disconnect() }) break case constants.denysoft: this.loop_respond(421, msg || 'Come back later') break case constants.denysoftdisconnect: this.respond(421, msg || 'Come back later', () => { this.disconnect() }) break default: { let greeting if (cfg.message.greeting?.length) { // RFC5321 section 4.2 // Hostname/domain should appear after the 220 greeting = [...cfg.message.greeting] greeting[0] = `${this.local.host} ESMTP ${greeting[0]}` if (cfg.uuid.banner_chars) { greeting[0] += ` (${this.uuid.slice(0, cfg.uuid.banner_chars)})` } } else { greeting = `${this.local.host} ESMTP ${this.local.info} ready` if (cfg.uuid.banner_chars) { greeting += ` (${this.uuid.slice(0, cfg.uuid.banner_chars)})` } } this.respond(220, msg || greeting) } } } get_remote(prop) { switch (this.remote[prop]) { case 'NXDOMAIN': case 'DNSERROR': case '': case undefined: case null: return `[${this.remote.ip}]` default: return `${this.remote[prop]} [${this.remote.ip}]` } } helo_respond(retval, msg) { switch (retval) { case constants.deny: this.respond(550, msg || 'HELO denied', () => { this.set('hello', 'verb', null) this.set('hello', 'host', null) }) break case constants.denydisconnect: this.respond(550, msg || 'HELO denied', () => { this.disconnect() }) break case constants.denysoft: this.respond(450, msg || 'HELO denied', () => { this.set('hello', 'verb', null) this.set('hello', 'host', null) }) break case constants.denysoftdisconnect: this.respond(450, msg || 'HELO denied', () => { this.disconnect() }) break default: // RFC5321 section 4.1.1.1 // Hostname/domain should appear after 250 this.respond(250, `${this.local.host} Hello ${this.get_remote('host')}, ${cfg.message.helo}`) } } ehlo_respond(retval, msg) { switch (retval) { case constants.deny: this.respond(550, msg || 'EHLO denied', () => { this.set('hello', 'verb', null) this.set('hello', 'host', null) }) break case constants.denydisconnect: this.respond(550, msg || 'EHLO denied', () => { this.disconnect() }) break case constants.denysoft: this.respond(450, msg || 'EHLO denied', () => { this.set('hello', 'verb', null) this.set('hello', 'host', null) }) break case constants.denysoftdisconnect: this.respond(450, msg || 'EHLO denied', () => { this.disconnect() }) break default: { // RFC5321 section 4.1.1.1 // Hostname/domain should appear after 250 const response = [ `${this.local.host} Hello ${this.get_remote('host')}, ${cfg.message.helo}`, 'PIPELINING', '8BITMIME', ] if (cfg.main.smtputf8) response.push('SMTPUTF8') response.push(`SIZE ${cfg.max.bytes}`) this.capabilities = response plugins.run_hooks('capabilities', this) this.esmtp = true } } } capabilities_respond() { this.respond(250, this.capabilities) } quit_respond(retval, msg) { this.respond(221, msg || `${this.local.host} ${cfg.message.close}`, () => { this.disconnect() }) } vrfy_respond(retval, msg) { switch (retval) { case constants.deny: this.respond(550, msg || 'Access Denied', () => { this.reset_transaction() }) break case constants.denydisconnect: this.respond(550, msg || 'Access Denied', () => { this.disconnect() }) break case constants.denysoft: this.respond(450, msg || 'Lookup Failed', () => { this.reset_transaction() }) break case constants.denysoftdisconnect: this.respond(450, msg || 'Lookup Failed', () => { this.disconnect() }) break case constants.ok: this.respond(250, msg || 'User OK') break default: this.respond(252, "Just try sending a mail and we'll see how it turns out...") } } noop_respond(retval, msg) { switch (retval) { case constants.deny: this.respond(500, msg || 'Stop wasting my time') break case constants.denydisconnect: this.respond(500, msg || 'Stop wasting my time', () => { this.disconnect() }) break default: this.respond(250, 'OK') } } rset_respond() { this.respond(250, 'OK', () => { this.reset_transaction() }) } mail_respond(retval, msg) { if (!this.transaction) { this.logerror('mail_respond found no transaction!') return } const sender = this.transaction.mail_from const dmsg = `sender ${sender.format()}` this.lognotice(dmsg, { code: constants.translate(retval), msg: msg || '', }) const store_results = (action) => { let addr = sender.format() if (addr.length > 2) { // all but null sender addr = addr.slice(1, -1) // trim off < > } this.transaction.results.add( { name: 'mail_from' }, { action, code: constants.translate(retval), address: addr, }, ) } switch (retval) { case constants.deny: this.respond(550, msg || `${dmsg} denied`, () => { store_results('reject') this.reset_transaction() }) break case constants.denydisconnect: this.respond(550, msg || `${dmsg} denied`, () => { store_results('reject') this.disconnect() }) break case constants.denysoft: this.respond(450, msg || `${dmsg} denied`, () => { store_results('tempfail') this.reset_transaction() }) break case constants.denysoftdisconnect: this.respond(450, msg || `${dmsg} denied`, () => { store_results('tempfail') this.disconnect() }) break default: store_results('accept') this.respond(250, msg || `${dmsg} OK`) } } rcpt_incr(rcpt, action, msg, retval) { this.transaction.rcpt_count[action]++ this.rcpt_count[action]++ const addr = rcpt.format() const recipient = { address: addr.slice(1, -1), action, } if (msg && action !== 'accept') { if (typeof msg === 'object' && msg.constructor.name === 'DSN') { recipient.msg = msg.reply recipient.code = msg.code } else { recipient.msg = msg recipient.code = constants.translate(retval) } } this.transaction.results.push({ name: 'rcpt_to' }, { recipient }) } rcpt_ok_respond(retval, msg) { if (!this.transaction) { this.results.add(this, { err: 'rcpt_ok_respond found no transaction', }) return } if (!msg) msg = this.last_rcpt_msg const rcpt = this.transaction.rcpt_to[this.transaction.rcpt_to.length - 1] const dmsg = `recipient ${rcpt.format()}` // Log OK instead of CONT as this hook only runs if hook_rcpt returns OK this.lognotice(dmsg, { code: constants.translate(retval === constants.cont ? constants.ok : retval), msg: msg || '', sender: this.transaction.mail_from.address, }) switch (retval) { case constants.deny: this.respond(550, msg || `${dmsg} denied`, () => { this.rcpt_incr(rcpt, 'reject', msg, retval) this.transaction.rcpt_to.pop() }) break case constants.denydisconnect: this.respond(550, msg || `${dmsg} denied`, () => { this.rcpt_incr(rcpt, 'reject', msg, retval) this.disconnect() }) break case constants.denysoft: this.respond(450, msg || `${dmsg} denied`, () => { this.rcpt_incr(rcpt, 'tempfail', msg, retval) this.transaction.rcpt_to.pop() }) break case constants.denysoftdisconnect: this.respond(450, msg || `${dmsg} denied`, () => { this.rcpt_incr(rcpt, 'tempfail', msg, retval) this.disconnect() }) break default: this.respond(250, msg || `${dmsg} OK`, () => { this.rcpt_incr(rcpt, 'accept', msg, retval) }) } } rcpt_respond(retval, msg) { if (retval === constants.cont && this.relaying) { retval = constants.ok } if (!this.transaction) { this.results.add(this, { err: 'rcpt_respond found no transaction', }) return } const rcpt = this.transaction.rcpt_to[this.transaction.rcpt_to.length - 1] const dmsg = `recipient ${rcpt.format()}` if (retval !== constants.ok) { this.lognotice(dmsg, { code: constants.translate(retval === constants.cont ? constants.ok : retval), msg: msg || '', sender: this.transaction.mail_from.address, }) } switch (retval) { case constants.deny: this.respond(550, msg || `${dmsg} denied`, () => { this.rcpt_incr(rcpt, 'reject', msg, retval) this.transaction.rcpt_to.pop() }) break case constants.denydisconnect: this.respond(550, msg || `${dmsg} denied`, () => { this.rcpt_incr(rcpt, 'reject', msg, retval) this.disconnect() }) break case constants.denysoft: this.respond(450, msg || `${dmsg} denied`, () => { this.rcpt_incr(rcpt, 'tempfail', msg, retval) this.transaction.rcpt_to.pop() }) break case constants.denysoftdisconnect: this.respond(450, msg || `${dmsg} denied`, () => { this.rcpt_incr(rcpt, 'tempfail', msg, retval) this.disconnect() }) break case constants.ok: // Store any msg for rcpt_ok this.last_rcpt_msg = msg plugins.run_hooks('rcpt_ok', this, rcpt) break default: { if (retval !== constants.cont) { this.logalert('No plugin determined if relaying was allowed') } const rej_msg = `I cannot deliver mail for ${rcpt.format()}` this.respond(550, rej_msg, () => { this.rcpt_incr(rcpt, 'reject', rej_msg, retval) this.transaction.rcpt_to.pop() }) } } } ///////////////////////////////////////////////////////////////////////////// // HAProxy support _clear_proxy_timer() { if (this.proxy.timer) { clearTimeout(this.proxy.timer) this.proxy.timer = null } } apply_proxy(proxy) { this._clear_proxy_timer() const { proto, src_ip, src_port, dst_ip, dst_port } = proxy const proxy_ip = proxy.proxy_ip || this.remote.ip // Apply changes this.loginfo('HAProxy', { proto, src_ip: `${new net_utils.Endpoint({ host: src_ip, port: src_port })}`, dst_ip: `${new net_utils.Endpoint({ host: dst_ip, port: dst_port })}`, }) this.notes.proxy = { type: 'haproxy', proto, src_ip, src_port, dst_ip, dst_port, proxy_ip, } this.reset_transaction(() => { this.set('proxy.ip', proxy_ip) this.set('proxy.type', 'haproxy') this.relaying = false this.set('local.ip', dst_ip) this.set('local.port', parseInt(dst_port, 10)) this.set('remote.ip', src_ip) this.set('remote.port', parseInt(src_port, 10)) this.set('remote.host', null) this.set('hello.host', null) this.results.add({ name: 'local' }, this.local) this.results.add({ name: 'remote' }, this.remote) plugins.run_hooks('connect_init', this) }) } cmd_proxy(line) { if (!this.proxy.allowed) { this.respond(421, `PROXY not allowed from ${this.remote.ip}`) return this.disconnect() } const proxy = net_utils.parse_proxy_line(line) if (!proxy) { this.respond(421, 'Invalid PROXY format') return this.disconnect() } this.apply_proxy(proxy) } ///////////////////////////////////////////////////////////////////////////// // SMTP Commands cmd_internalcmd(line) { if (!this.remote.is_local) { return this.respond(501, 'INTERNALCMD not allowed remotely') } const results = String(line).split(/ +/) if (/key:/.test(results[0])) { const internal_key = config.get('internalcmd_key') if (results[0] != `key:${internal_key}`) { return this.respond(501, 'Invalid internalcmd_key - check config') } results.shift() } else if (config.get('internalcmd_key')) { return this.respond(501, 'Missing internalcmd_key - check config') } // Now send the internal command to the master process const command = results.shift() if (!command) { return this.respond(501, 'No command given') } require('./server').sendToMaster(command, results) return this.respond(250, 'Command sent for execution. Check Haraka logs for results.') } cmd_helo(line) { const results = String(line).split(/ +/) const host = results[0] if (!host) { return this.respond(501, 'HELO requires domain/address - see RFC-2821 4.1.1.1') } // RFC 5321 §4.1.1.1: the domain/address-literal cannot contain // control characters. process_line() only strips the first \r?\n, // so a bare \r could otherwise survive into hello.host and the // generated Received: header / logs (header injection). if (/[\x00-\x1f\x7f]/.test(host)) { return this.respond(501, 'HELO syntax error - see RFC-2821 4.1.1.1') } this.reset_transaction(() => { this.set('hello', 'verb', 'HELO') this.set('hello', 'host', host) this.results.add({ name: 'helo' }, this.hello) plugins.run_hooks('helo', this, host) }) } cmd_ehlo(line) { const results = String(line).split(/ +/) const host = results[0] if (!host) { return this.respond(501, 'EHLO requires domain/address - see RFC-2821 4.1.1.1') } // RFC 5321 §4.1.1.1: reject control chars (see cmd_helo). if (/[\x00-\x1f\x7f]/.test(host)) { return this.respond(501, 'EHLO syntax error - see RFC-2821 4.1.1.1') } this.reset_transaction(() => { this.set('hello', 'verb', 'EHLO') this.set('hello', 'host', host) this.results.add({ name: 'helo' }, this.hello) plugins.run_hooks('ehlo', this, host) }) } cmd_quit(args) { // RFC 5321 Section 4.3.2 // QUIT does not accept arguments if (args) { return this.respond(501, 'Syntax error') } plugins.run_hooks('quit', this) } cmd_rset(args) { // RFC 5321 Section 4.3.2 // RSET does not accept arguments if (args) { return this.respond(501, 'Syntax error') } plugins.run_hooks('rset', this) } cmd_vrfy() { // only supported via plugins plugins.run_hooks('vrfy', this) } cmd_noop() { plugins.run_hooks('noop', this) } cmd_help() { this.respond(250, 'Not implemented') } cmd_mail(line) { if (!this.hello.host) { this.errors++ return this.respond(503, 'Use EHLO/HELO before MAIL') } // Require authentication on ports 587 & 465 if (!this.relaying && [587, 465].includes(this.local.port)) { this.errors++ return this.respond(550, 'Authentication required') } let results try { results = rfc1869.parse('mail', line, !this.relaying && cfg.main.strict_rfc1869) } catch (err) { this.errors++ if (err.stack) { this.lognotice(err.stack.split(/\n/)[0]) } else { this.logerror(err) } // Explicitly handle out-of-disk space errors if (err.code === 'ENOSPC') { return this.respond(452, 'Internal Server Error') } else { return this.respond(501, ['Command parsing failed', err]) } } let from const from_raw = results.shift() try { from = new Address(from_raw, { postel: cfg.main.postel }) } catch (err) { const msg = `Invalid MAIL FROM address ${utils.sanitize(from_raw)}: ${err.message}` this.lognotice(msg) return this.respond(501, msg) } // Get rest of key=value pairs const params = {} for (const param of results) { const kv = param.match(/^([^=]+)(?:=(.+))?$/) if (kv) params[kv[1].toUpperCase()] = kv[2] || null } // Parameters are only valid if EHLO was sent if (!this.esmtp && Object.keys(params).length > 0) { return this.respond(555, 'Invalid command parameters') } // Handle SIZE extension if (params?.SIZE && params.SIZE > 0) { if (cfg.max.bytes > 0 && params.SIZE > cfg.max.bytes) { return this.respond(550, 'Message too big!') } } this.init_transaction(() => { this.transaction.mail_from = from if (this.hello.verb == 'HELO') { this.transaction.encoding = 'binary' this.encoding = 'binary' } plugins.run_hooks('mail', this, [from, params]) }) } cmd_rcpt(line) { if (!this.transaction || !this.transaction.mail_from) { this.errors++ return this.respond(503, 'Use MAIL before RCPT') } let results try { results = rfc1869.parse('rcpt', line, cfg.main.strict_rfc1869 && !this.relaying) } catch (err) { this.errors++ if (err.stack) { this.lognotice(err.stack.split(/\n/)[0]) } else { this.logerror(err) } // Explicitly handle out-of-disk space errors if (err.code === 'ENOSPC') { return this.respond(452, 'Internal Server Error') } else { return this.respond(501, ['Command parsing failed', err]) } } let recip const recip_raw = results.shift() try { recip = new Address(recip_raw, { postel: cfg.main.postel }) } catch (err) { const msg = `Invalid RCPT TO address ${utils.sanitize(recip_raw)}: ${err.message}` this.lognotice(msg) return this.respond(501, msg) } // Get rest of key=value pairs const params = {} for (const param of results) { const kv = param.match(/^([^=]+)(?:=(.+))?$/) if (kv) params[kv[1].toUpperCase()] = kv[2] || null } // Parameters are only valid if EHLO was sent if (!this.esmtp && Object.keys(params).length > 0) { return this.respond(555, 'Invalid command parameters') } this.transaction.rcpt_to.push(recip) plugins.run_hooks('rcpt', this, [recip, params]) } received_line() { let smtp = this.hello.verb === 'EHLO' ? 'ESMTP' : 'SMTP' // Implement RFC3848 if (this.tls.enabled) smtp += 'S' if (this.authheader) smtp += 'A' let sslheader if (this.get('tls.cipher.version')) { // standardName appeared in Node.js v12.16 and v13.4 // RFC 8314 sslheader = `tls ${this.tls.cipher.standardName || this.tls.cipher.name}` } let received_header = `from ${this.hello.host} (${this.get_remote('info')})\r \tby ${this.local.host} (${this.local.info}) with ${smtp} id ${this.transaction.uuid}\r \tenvelope-from ${this.transaction.mail_from.format()}` if (sslheader) received_header += `\r\n\t${sslheader.replace(/\r?\n\t?$/, '')}` // Does not follow RFC 5321 section 4.4 grammar if (this.authheader) received_header += ` ${this.authheader.replace(/\r?\n\t?$/, '')}` received_header += `;\r\n\t${utils.date_to_str(new Date())}` return received_header } auth_results(message) { // https://datatracker.ietf.org/doc/rfc7001/ const has_tran = !!this.transaction?.notes // initialize connection note if (!this.notes.authentication_results) { this.notes.authentication_results = [] } // initialize transaction note, if possible if (has_tran === true && !this.transaction.notes.authentication_results) { this.transaction.notes.authentication_results = [] } // Strip CR/LF and other control chars: an attacker-influenced // value (e.g. a failed AUTH username, see auth_base) must not be // able to inject extra header lines into Authentication-Results. // The legitimate folding (;\r\n\t) is added by the join below. // if message, store it in the appropriate note if (message) { if (has_tran === true) { this.transaction.notes.authentication_results.push(utils.sanitize(message)) } else { this.notes.authentication_results.push(utils.sanitize(message)) } } // assemble the new header let header = [utils.sanitize(this.local.host), ...this.notes.authentication_results] if (has_tran === true) { header = [...header, ...this.transaction.notes.authentication_results] } if (header.length === 1) return '' // no results return header.join(';\r\n\t') } auth_results_clean() { // move any existing Auth-Res headers to Original-Auth-Res headers // http://tools.ietf.org/html/draft-kucherawy-original-authres-00.html const ars = this.transaction.header.get_all('Authentication-Results') if (ars.length === 0) return for (const element of ars) { this.transaction.add_header('Original-Authentication-Results', element) } this.transaction.remove_header('Authentication-Results') this.logdebug('Authentication-Results moved to Original-Authentication-Results') } cmd_data(args) { // RFC 5321 Section 4.3.2 // DATA does not accept arguments if (args) { this.errors++ return this.respond(501, 'Syntax error') } if (!this.transaction) { this.errors++ return this.respond(503, 'MAIL required first') } if (!this.transaction.rcpt_to.length) { if (this.pipelining) { return this.respond(554, 'No valid recipients') } this.errors++ return this.respond(503, 'RCPT required first') } if (cfg.headers.add_received) { this.accumulate_data(`Received: ${this.received_line()}\r\n`) } plugins.run_hooks('data', this) } data_respond(retval, msg) { let cont = 0 switch (retval) { case constants.deny: this.respond(554, msg || 'Message denied', () => { this.reset_transaction() }) break case constants.denydisconnect: this.respond(554, msg || 'Message denied', () => { this.disconnect() }) break case constants.denysoft: this.respond(451, msg || 'Message denied', () => { this.reset_transaction() }) break case constants.denysoftdisconnect: this.respond(451, msg || 'Message denied', () => { this.disconnect() }) break default: cont = 1 } if (!cont) return // We already checked for MAIL/RCPT in cmd_data this.respond(354, 'go ahead, make my day', () => { // OK... now we get the data this.state = states.DATA this.transaction.data_bytes = 0 }) } accumulate_data(line) { this.transaction.data_bytes += line.length // Look for .\r\n if (line.length === 3 && line[0] === 0x2e && line[1] === 0x0d && line[2] === 0x0a) { this.data_done() return } // Look for .\n if (line.length === 2 && line[0] === 0x2e && line[1] === 0x0a) { this.lognotice('Client sent bare line-feed - .\\n rather than .\\r\\n') this.respond(451, 'Bare line-feed; see https://haraka.github.io/barelf/', () => { this.disconnect() }) return } // The line stream also splits on a bare LF, so . would end DATA here // while a CRLF-only peer forwards the same bytes as content (SMTP smuggling). if (line[line.length - 1] === 0x0a && line[line.length - 2] !== 0x0d) { this.lognotice('Client sent bare line-feed inside DATA') this.respond(451, 'Bare line-feed; see https://haraka.github.io/barelf/', () => { this.disconnect() }) return } // Stop accumulating data as we're going to reject at dot. if (cfg.max.bytes && this.transaction.data_bytes > cfg.max.bytes) { return } if (this.transaction.mime_part_count >= cfg.max.mime_parts) { this.logcrit('Possible DoS attempt - too many MIME parts') this.respond(554, 'Transaction failed due to too many MIME parts', () => { this.disconnect() }) return } this.transaction.add_data(line) } data_done() { // Soft pause: keep reading the socket so a command pipelined after the // terminator lands in current_data. A hard client.pause() would leave it // in the kernel queue, invisible to a buffer check. PAUSE_DATA still stops // it being processed until resume(). if (this.state !== states.PAUSE_DATA) this.prev_state = this.state this.state = states.PAUSE_DATA this.awaiting_data_reply = true this.totalbytes += this.transaction.data_bytes // Check message size limit if (cfg.max.bytes && this.transaction.data_bytes > cfg.max.bytes) { this.lognotice(`Incoming message exceeded max size of ${cfg.max.bytes}`) return plugins.run_hooks('max_data_exceeded', this) } // Check max received headers count if (this.transaction.header.get_all('received').length > cfg.headers.max_received) { this.logerror('Incoming message had too many Received headers') this.respond(550, 'Too many received headers - possible mail loop', () => { this.reset_transaction() }) return } // Warn if we hit the maximum parsed header lines limit if (this.transaction.header_lines.length >= cfg.headers.max_lines) { this.logwarn(`Incoming message reached maximum parsing limit of ${cfg.headers.max_lines} header lines`) } if (cfg.headers.clean_auth_results) { this.auth_results_clean() // rename old A-R headers } const ar_field = this.auth_results() // assemble new one if (ar_field) { this.transaction.add_header('Authentication-Results', ar_field) } this.transaction.end_data(() => { // As this will be called asynchronously, // make sure we still have a transaction. if (!this.transaction) return // Record the start time of this hook as we can't take too long // as the client will typically hang up after 2 to 3 minutes // despite the RFC mandating that 10 minutes should be allowed. this.transaction.data_post_start = Date.now() plugins.run_hooks('data_post', this) }) } data_post_respond(retval, msg) { if (!this.transaction) return this.transaction.data_post_delay = (Date.now() - this.transaction.data_post_start) / 1000 const mid = utils.sanitize(this.transaction.header.get('Message-ID') || '', { replacement: ' ' }).trim() this.lognotice('message', { mid, size: this.transaction.data_bytes, rcpts: `${this.transaction.rcpt_count.accept}/${this.transaction.rcpt_count.tempfail}/${this.transaction.rcpt_count.reject}`, delay: this.transaction.data_post_delay, code: constants.translate(retval), msg: msg || '', }) const ar_field = this.auth_results() // assemble A-R header if (ar_field) { this.transaction.remove_header('Authentication-Results') this.transaction.add_leading_header('Authentication-Results', ar_field) } switch (retval) { case constants.deny: this.respond(550, msg || 'Message denied', () => { this.msg_count.reject++ this.transaction.msg_status = 'rejected' this.reset_transaction(() => this.resume()) }) break case constants.denydisconnect: this.respond(550, msg || 'Message denied', () => { this.msg_count.reject++ this.transaction.msg_status = 'rejected' this.disconnect() }) break case constants.denysoft: this.respond(450, msg || 'Message denied temporarily', () => { this.msg_count.tempfail++ this.transaction.msg_status = 'deferred' this.reset_transaction(() => this.resume()) }) break case constants.denysoftdisconnect: this.respond(450, msg || 'Message denied temporarily', () => { this.msg_count.tempfail++ this.transaction.msg_status = 'deferred' this.disconnect() }) break default: if (this.relaying) { plugins.run_hooks('queue_outbound', this) } else { plugins.run_hooks('queue', this) } } } max_data_exceeded_respond(retval) { // TODO: Maybe figure out what to do with other return codes this.respond(retval === constants.denysoft ? 450 : 550, 'Message too big!', () => { this.reset_transaction() }) } queue_msg(retval, msg) { if (msg) { if (typeof msg === 'object' && msg.constructor.name === 'DSN') { return msg.reply } return msg } switch (retval) { case constants.ok: return 'Message Queued' case constants.deny: case constants.denydisconnect: return 'Message denied' case constants.denysoft: case constants.denysoftdisconnect: return 'Message denied temporarily' default: return '' } } store_queue_result(retval, msg) { const res_as = { name: 'queue' } switch (retval) { case constants.ok: this.transaction.results.add(res_as, { pass: msg }) break case constants.deny: case constants.denydisconnect: this.transaction.results.add(res_as, { fail: msg }) break case constants.denysoft: case constants.denysoftdisconnect: this.transaction.results.add(res_as, { fail: msg, soft: true }) break case constants.cont: break default: this.transaction.results.add(res_as, { msg }) break } } queue_outbound_respond(retval, msg) { if (this.remote.closed) return msg = this.queue_msg(retval, msg) || 'Message Queued' this.store_queue_result(retval, msg) msg = `${msg} (${this.transaction.uuid})` if (retval !== constants.ok) { this.lognotice('queue', { code: constants.translate(retval), msg, }) } switch (retval) { case constants.ok: plugins.run_hooks('queue_ok', this, msg) break case constants.deny: this.respond(550, msg, () => { this.msg_count.reject++ this.transaction.msg_status = 'rejected' this.reset_transaction(() => this.resume()) }) break case constants.denydisconnect: this.respond(550, msg, () => { this.msg_count.reject++ this.transaction.msg_status = 'rejected' this.disconnect() }) break case constants.denysoft: this.respond(450, msg, () => { this.msg_count.tempfail++ this.transaction.msg_status = 'deferred' this.reset_transaction(() => this.resume()) }) break case constants.denysoftdisconnect: this.respond(450, msg, () => { this.msg_count.tempfail++ this.transaction.msg_status = 'deferred' this.disconnect() }) break default: outbound.send_trans_email(this.transaction, (retval2, msg2) => { if (!msg2) msg2 = this.queue_msg(retval2, msg) switch (retval2) { case constants.ok: if (!msg2) msg2 = this.queue_msg(retval2, msg2) plugins.run_hooks('queue_ok', this, msg2) break case constants.deny: if (!msg2) msg2 = this.queue_msg(retval2, msg2) this.respond(550, msg2, () => { this.msg_count.reject++ this.transaction.msg_status = 'rejected' this.reset_transaction(() => { this.resume() }) }) break default: this.logerror(`Unrecognized response from outbound layer: ${retval2} : ${msg2}`) this.respond(550, msg2 || 'Internal Server Error', () => { this.msg_count.reject++ this.transaction.msg_status = 'rejected' this.reset_transaction(() => { this.resume() }) }) } }) } } queue_respond(retval, msg) { msg = this.queue_msg(retval, msg) this.store_queue_result(retval, msg) msg = `${msg} (${this.transaction.uuid})` if (retval !== constants.ok) { this.lognotice('queue', { code: constants.translate(retval), msg, }) } switch (retval) { case constants.ok: plugins.run_hooks('queue_ok', this, msg) break case constants.deny: this.respond(550, msg, () => { this.msg_count.reject++ this.transaction.msg_status = 'rejected' this.reset_transaction(() => this.resume()) }) break case constants.denydisconnect: this.respond(550, msg, () => { this.msg_count.reject++ this.transaction.msg_status = 'rejected' this.disconnect() }) break case constants.denysoft: this.respond(450, msg, () => { this.msg_count.tempfail++ this.transaction.msg_status = 'deferred' this.reset_transaction(() => this.resume()) }) break case constants.denysoftdisconnect: this.respond(450, msg, () => { this.msg_count.tempfail++ this.transaction.msg_status = 'deferred' this.disconnect() }) break default: if (!msg) msg = 'Queuing declined or disabled, try later' this.respond(451, msg, () => { this.msg_count.tempfail++ this.transaction.msg_status = 'deferred' this.reset_transaction(() => this.resume()) }) break } } queue_ok_respond(retval, msg, params) { // This hook is common to both hook_queue and hook_queue_outbound // retval and msg are ignored in this hook so we always log OK this.lognotice('queue', { code: 'OK', msg: params || '', }) this.respond(250, params, () => { this.msg_count.accept++ if (this.transaction) this.transaction.msg_status = 'accepted' this.reset_transaction(() => this.resume()) }) } } exports.Connection = Connection exports.cfg = cfg exports.createConnection = (client, server, cfg) => { return new Connection(client, server, cfg) } logger.add_log_methods(Connection)