'use strict' // An SMTP Transaction const util = require('node:util') const Notes = require('haraka-notes') const utils = require('haraka-utils') const message = require('haraka-email-message') class Transaction { constructor(uuid, cfg = {}) { this.uuid = uuid || utils.uuid() this.cfg = cfg this.mail_from = null this.rcpt_to = [] this.header_lines = [] this.data_lines = [] this.attachment_start_hooks = [] this.banner = null this.body_filters = [] this.data_bytes = 0 this.header_pos = 0 this.found_hb_sep = false this.body = null this.parse_body = false this.notes = new Notes() this.notes.skip_plugins = [] this.header = new message.Header() this.message_stream = new message.stream(this.cfg, this.uuid, this.header.header_list) this.discard_data = false this.resetting = false this.rcpt_count = { accept: 0, tempfail: 0, reject: 0, } this.msg_status = undefined this.data_post_start = null this.data_post_delay = 0 this.encoding = 'utf8' this.mime_part_count = 0 } ensure_body() { if (this.body) return this.body = new message.Body(this.header) this.body.on('mime_boundary', () => this.incr_mime_count()) for (const hook of this.attachment_start_hooks) { this.body.on('attachment_start', hook) } if (this.banner) this.body.set_banner(this.banner) for (const o of this.body_filters) { this.body.add_filter((ct, enc, buf) => { if (!ct) return buf const ct_lc = String(ct).toLowerCase().trim() const re_match = util.types.isRegExp(o.ct_match) && o.ct_match.test(ct_lc) const ct_begins = ct_lc.indexOf(String(o.ct_match).toLowerCase()) === 0 if (re_match || ct_begins) return o.filter(ct, enc, buf) || buf return buf }) } } // Removes the CR of a CRLF newline at the end of the buffer. remove_final_cr(data) { if (data.length < 2) return data if (!Buffer.isBuffer(data)) data = Buffer.from(data) if (data[data.length - 2] === 0x0d && data[data.length - 1] === 0x0a) { data[data.length - 2] = 0x0a return data.slice(0, data.length - 1) } return data } // Duplicates any '.' chars at the beginning of a line (dot-stuffing) and // ensures all newlines are CRLF. add_dot_stuffing_and_ensure_crlf_newlines(data) { if (!data.length) return data if (!Buffer.isBuffer(data)) data = Buffer.from(data) // Make a new buffer big enough to hold two bytes for every one input // byte. At most, we add one extra character per input byte, so this // is always big enough. We allocate it "unsafe" (i.e. no memset) for // speed because we're about to fill it with data, and the remainder of // the space we don't fill will be sliced away before we return this. const output = Buffer.allocUnsafe(data.length * 2) let output_pos = 0 let input_pos = 0 let next_dot = data.indexOf(0x2e) let next_lf = data.indexOf(0x0a) while (next_dot !== -1 || next_lf !== -1) { const run_end = next_dot !== -1 && (next_lf === -1 || next_dot < next_lf) ? next_dot : next_lf // Copy up to whichever comes first, '.' or '\n' (but don't // copy the '.' or '\n' itself). data.copy(output, output_pos, input_pos, run_end) output_pos += run_end - input_pos if (data[run_end] === 0x2e && (run_end === 0 || data[run_end - 1] === 0x0a)) { // Replace /^\./ with '..' output[output_pos++] = 0x2e } else if (data[run_end] === 0x0a && (run_end === 0 || data[run_end - 1] !== 0x0d)) { // Replace /\r?\n/ with '\r\n' output[output_pos++] = 0x0d } output[output_pos++] = data[run_end] input_pos = run_end + 1 if (run_end === next_dot) { next_dot = data.indexOf(0x2e, input_pos) } else { next_lf = data.indexOf(0x0a, input_pos) } } if (input_pos < data.length) { data.copy(output, output_pos, input_pos) output_pos += data.length - input_pos } return output.slice(0, output_pos) } add_data(line) { if (typeof line === 'string') { // This shouldn't ever happen. line = Buffer.from(line, this.encoding) } // is this the end of headers line? if (this.header_pos === 0 && (line[0] === 0x0a || (line[0] === 0x0d && line[1] === 0x0a))) { this.header.parse(this.header_lines) this.header_pos = this.header.header_list ? this.header.header_list.length : -1 this.found_hb_sep = true if (this.parse_body) this.ensure_body() } else if (this.header_pos === 0) { // Build up headers if (this.header_lines.length < (this.cfg?.headers?.max_lines || 1000)) { // De-stuff one leading dot for the parsed header value (RFC 5321 // ยง4.5.2), but keep the original dot-stuffed `line` for message_stream const header_line = line[0] === 0x2e ? line.slice(1) : line this.header_lines.push(header_line.toString(this.encoding).replace(/\r\n$/, '\n')) } } else if (this.parse_body) { this.ensure_body() let new_line = line if (new_line[0] === 0x2e) new_line = new_line.slice(1) // Strip leading "." line = this.add_dot_stuffing_and_ensure_crlf_newlines(this.body.parse_more(this.remove_final_cr(new_line))) if (!line.length) return // buffering for banners } if (!this.discard_data) this.message_stream.add_line(line) } end_data(cb) { if (!this.found_hb_sep && this.header_lines.length) { // Headers not parsed yet - must be a busted email // Strategy: Find the first line that doesn't look like a header. // Treat anything before that as headers, anything after as body. let header_pos = 0 for (let i = 0; i < this.header_lines.length; i++) { // Anything that doesn't match a header or continuation if (!/^(?:([^\s:]*):\s*([\s\S]*)$|[ \t])/.test(this.header_lines[i])) { break } header_pos = i } const body_lines = this.header_lines.splice(header_pos + 1) this.header.parse(this.header_lines) this.header_pos = this.header.header_list ? this.header.header_list.length : -1 this.found_hb_sep = true // We MUST add an empty line to the message_stream so that it knows // where the headers end. Otherwise it skips everything. if (!this.discard_data) this.message_stream.add_line(Buffer.from('\r\n')) if (this.parse_body) { this.ensure_body() for (const bodyLine of body_lines) { this.body.parse_more(bodyLine) } } // header_lines holds de-stuffed values for the header parser; a line // reclassified as body must go back to wire form for (const bodyLine of body_lines) { if (this.discard_data) continue const wire = this.add_dot_stuffing_and_ensure_crlf_newlines(bodyLine.replace(/\r?\n?$/, '\n')) this.message_stream.add_line(wire) } } if (this.header_pos && this.parse_body) { const line = this.add_dot_stuffing_and_ensure_crlf_newlines(this.body.parse_end()) if (line.length) { this.body.force_end() if (!this.discard_data) this.message_stream.add_line(line) } } if (this.discard_data) { cb() } else { this.message_stream.add_line_end(cb) } } add_header(key, value) { this.header.add_end(key, value) if (this.found_hb_sep) this.reset_headers() } add_leading_header(key, value) { this.header.add(key, value) if (this.found_hb_sep) this.reset_headers() } reset_headers() { this.header_pos = this.header.header_list ? this.header.header_list.length : -1 } remove_header(key) { this.header.remove(key) if (this.found_hb_sep) this.reset_headers() } attachment_hooks(start) { this.parse_body = true this.attachment_start_hooks.push(start) } set_banner(text, html) { // throw "transaction.set_banner is currently non-functional"; this.parse_body = true if (!html) { html = text.replace(/\n/g, '
\n') } this.banner = [text, html] } add_body_filter(ct_match, filter) { this.parse_body = true this.body_filters.push({ ct_match, filter }) if (this.body) { this.body.add_filter((ct, enc, buf) => { if (!ct) return buf const ct_lc = String(ct).toLowerCase().trim() const re_match = util.types.isRegExp(ct_match) && ct_match.test(ct_lc) const ct_begins = ct_lc.indexOf(String(ct_match).toLowerCase()) === 0 if (re_match || ct_begins) return filter(ct, enc, buf) || buf return buf }) } } incr_mime_count() { this.mime_part_count++ } } exports.Transaction = Transaction exports.createTransaction = (uuid, cfg) => { return new Transaction(uuid, cfg) }