'use strict'; const Transform = require('stream').Transform; const MimeNode = require('./mime-node'); const MAX_HEAD_SIZE = 1 * 1024 * 1024; const MAX_CHILD_NODES = 1000; const HEAD = 0x01; const BODY = 0x02; class MessageSplitter extends Transform { constructor(config) { let options = { readableObjectMode: true, writableObjectMode: false }; super(options); this.config = config || {}; this.maxHeadSize = this.config.maxHeadSize || MAX_HEAD_SIZE; this.maxChildNodes = this.config.maxChildNodes || MAX_CHILD_NODES; this.tree = []; this.nodeCounter = 0; this.newNode(); this.tree.push(this.node); this.line = false; this.errored = false; } _transform(chunk, encoding, callback) { // process line by line // find next line ending let pos = 0; let i = 0; let group = { type: 'none' }; let groupstart = this.line ? -this.line.length : 0; let groupend = 0; let checkTrailingLinebreak = data => { if (data.type === 'body' && data.node.parentNode && data.value && data.value.length) { if (data.value[data.value.length - 1] === 0x0a) { groupstart--; groupend--; pos--; if (data.value.length > 1 && data.value[data.value.length - 2] === 0x0d) { groupstart--; groupend--; pos--; if (groupstart < 0 && !this.line) { // store only as should be on the positive side this.line = Buffer.allocUnsafe(1); this.line[0] = 0x0d; } data.value = data.value.slice(0, data.value.length - 2); } else { data.value = data.value.slice(0, data.value.length - 1); } } else if (data.value[data.value.length - 1] === 0x0d) { groupstart--; groupend--; pos--; data.value = data.value.slice(0, data.value.length - 1); } } }; let iterateData = () => { for (let len = chunk.length; i < len; i++) { // find next if (chunk[i] === 0x0a) { // line end let start = Math.max(pos, 0); pos = ++i; return this.processLine(chunk.slice(start, i), false, (err, data, flush) => { if (err) { this.errored = true; return setImmediate(() => callback(err)); } if (!data) { return setImmediate(iterateData); } if (flush) { if (group && group.type !== 'none') { if (group.type === 'body' && groupend >= groupstart && group.node.parentNode) { // do not include the last line ending for body if (chunk[groupend - 1] === 0x0a) { groupend--; if (groupend >= groupstart && chunk[groupend - 1] === 0x0d) { groupend--; } } } if (groupstart !== groupend) { group.value = chunk.slice(groupstart, groupend); if (groupend < i) { data.value = chunk.slice(groupend, i); } } this.push(group); group = { type: 'none' }; groupstart = groupend = i; } this.push(data); groupend = i; return setImmediate(iterateData); } if (data.type === group.type) { // shift slice end position forward groupend = i; } else { if (group.type === 'body' && groupend >= groupstart && group.node.parentNode) { // do not include the last line ending for body if (chunk[groupend - 1] === 0x0a) { groupend--; if (groupend >= groupstart && chunk[groupend - 1] === 0x0d) { groupend--; } } } if (group.type !== 'none' && group.type !== 'node') { // we have a previous data/body chunk to output if (groupstart !== groupend) { group.value = chunk.slice(groupstart, groupend); if (group.value && group.value.length) { this.push(group); group = { type: 'none' }; } } } if (data.type === 'node') { this.push(data); groupstart = i; groupend = i; } else if (groupstart < 0) { groupstart = i; groupend = i; checkTrailingLinebreak(data); if (data.value && data.value.length) { this.push(data); } } else { // start new body/data chunk group = data; groupstart = groupend; groupend = i; } } return setImmediate(iterateData); }); } } // skip last linebreak for body if (pos >= groupstart + 1 && group.type === 'body' && group.node.parentNode) { // do not include the last line ending for body if (chunk[pos - 1] === 0x0a) { pos--; if (pos >= groupstart && chunk[pos - 1] === 0x0d) { pos--; } } } if (group.type !== 'none' && group.type !== 'node' && pos > groupstart) { // we have a leftover data/body chunk to push out group.value = chunk.slice(groupstart, pos); if (group.value && group.value.length) { this.push(group); group = { type: 'none' }; } } if (pos < chunk.length) { if (this.line) { this.line = Buffer.concat([this.line, chunk.slice(pos)]); } else { this.line = chunk.slice(pos); } } callback(); }; setImmediate(iterateData); } _flush(callback) { if (this.errored) { return callback(); } this.processLine(false, true, (err, data) => { if (err) { return setImmediate(() => callback(err)); } if (data && (data.type === 'node' || (data.value && data.value.length))) { this.push(data); } callback(); }); } compareBoundary(line, startpos, boundary) { // --{boundary}\r\n or --{boundary}--\r\n if (line.length < boundary.length + 3 + startpos || line.length > boundary.length + 6 + startpos) { return false; } for (let i = 0; i < boundary.length; i++) { if (line[i + 2 + startpos] !== boundary[i]) { return false; } } let pos = 0; for (let i = boundary.length + 2 + startpos; i < line.length; i++) { let c = line[i]; if (pos === 0 && (c === 0x0d || c === 0x0a)) { // 1: next node return 1; } if (pos === 0 && c !== 0x2d) { // expecting "-" return false; } if (pos === 1 && c !== 0x2d) { // expecting "-" return false; } if (pos === 2 && c !== 0x0d && c !== 0x0a) { // expecting line terminator, either or return false; } if (pos === 3 && c !== 0x0a) { // expecting line terminator return false; } pos++; } // 2: multipart end return 2; } checkBoundary(line) { let startpos = 0; if (line.length >= 1 && (line[0] === 0x0d || line[0] === 0x0a)) { startpos++; if (line.length >= 2 && (line[0] === 0x0d || line[1] === 0x0a)) { startpos++; } } if (line.length < 4 || line[startpos] !== 0x2d || line[startpos + 1] !== 0x2d) { // defnitely not a boundary return false; } let boundary; if (this.node._boundary && (boundary = this.compareBoundary(line, startpos, this.node._boundary))) { // 1: next child // 2: multipart end return boundary; } if (this.node._parentBoundary && (boundary = this.compareBoundary(line, startpos, this.node._parentBoundary))) { // 3: next sibling // 4: parent end return boundary + 2; } return false; } processLine(line, final, next) { let flush = false; if (this.line && line) { line = Buffer.concat([this.line, line]); this.line = false; } else if (this.line && !line) { line = this.line; this.line = false; } if (!line) { line = Buffer.alloc(0); } if (this.nodeCounter > this.maxChildNodes) { let err = new Error('Max allowed child nodes exceeded'); err.code = 'EMAXLEN'; return next(err); } // we check boundary outside the HEAD/BODY scope as it may appear anywhere let boundary = this.checkBoundary(line); if (boundary) { // reached boundary, switch context switch (boundary) { case 1: // next child this.newNode(this.node); flush = true; break; case 2: // reached end of children, keep current node break; case 3: { // next sibling let parentNode = this.node.parentNode; if (parentNode && parentNode.contentType === 'message/rfc822') { // special case where immediate parent is an inline message block // move up another step parentNode = parentNode.parentNode; } this.newNode(parentNode); flush = true; break; } case 4: // special case when boundary close a node with only header. if (this.node && this.node._headerlen && !this.node.headers) { this.node.parseHeaders(); this.push(this.node); } // move up if (this.tree.length) { this.node = this.tree.pop(); } this.state = BODY; break; } return next( null, { node: this.node, type: 'data', value: line }, flush ); } switch (this.state) { case HEAD: { this.node.addHeaderChunk(line); if (this.node._headerlen > this.maxHeadSize) { let err = new Error('Max header size for a MIME node exceeded'); err.code = 'EMAXLEN'; return next(err); } if (final || (line.length === 1 && line[0] === 0x0a) || (line.length === 2 && line[0] === 0x0d && line[1] === 0x0a)) { let currentNode = this.node; currentNode.parseHeaders(); // if the content is attached message then just continue if ( currentNode.contentType === 'message/rfc822' && !this.config.ignoreEmbedded && (!currentNode.encoding || ['7bit', '8bit', 'binary'].includes(currentNode.encoding)) && currentNode.disposition !== 'attachment' ) { currentNode.messageNode = true; this.newNode(currentNode); if (currentNode.parentNode) { this.node._parentBoundary = currentNode.parentNode._boundary; } } else { if (currentNode.contentType === 'message/rfc822') { currentNode.messageNode = false; } this.state = BODY; if (currentNode.multipart && currentNode._boundary) { this.tree.push(currentNode); } } return next(null, currentNode, flush); } return next(); } case BODY: { return next( null, { node: this.node, type: this.node.multipart ? 'data' : 'body', value: line }, flush ); } } next(null, false); } newNode(parent) { this.node = new MimeNode(parent || false, this.config); this.state = HEAD; this.nodeCounter++; } } module.exports = MessageSplitter;