maildev
Version:
SMTP Server and Web Interface for reading and testing emails during development
1,453 lines (1,258 loc) • 48.3 kB
JavaScript
;
/**
* @fileOverview This is the main file for the MailParser library to parse raw e-mail data
* @author <a href="mailto:andris@node.ee">Andris Reinman</a>
* @version 0.2.23
*/
var Stream = require("stream").Stream,
utillib = require("util"),
datetime = require("./datetime"),
Streams = require("./streams"),
crypto = require("crypto"),
mime = require("mime");
const addressparser = require('addressparser');
const charset = require('./charset');
const libmime = require('./libmime');
const libqp = require('./libqp');
// Expose to the world
module.exports.MailParser = MailParser;
// MailParser is a FSM - it is always in one of the possible states
var STATES = {
header: 0x1,
body: 0x2,
finished: 0x3
};
/**
* Taken from mimelib
*/
const parseAddresses = function(addresses) {
const parsed = addressparser(addresses);
return parsed.map(function(address) {
address.name = libmime.decodeWords(address.name);
if (address.group) {
address.group.forEach(function(groupAddress) {
groupAddress.name = libmime.decodeWords(groupAddress.name);
});
}
return address;
});
};
/**
* <p>Creates instance of MailParser which in turn extends Stream</p>
*
* <p>Options object has the following properties:</p>
*
* <ul>
* <li><b>debug</b> - if set to true print all incoming lines to decodeq</li>
* <li><b>streamAttachments</b> - if set to true, stream attachments instead of including them</li>
* <li><b>unescapeSMTP</b> - if set to true replace double dots in the beginning of the file</li>
* <li><b>defaultCharset</b> - the default charset for text/plain, text/html content, if not set reverts to Latin-1
* <li><b>showAttachmentLinks</b></li> - if set to true, show inlined attachment links
* </ul>
*
* @constructor
* @param {Object} [options] Optional options object
*/
function MailParser(options) {
// Make MailParser a Stream object
Stream.call(this);
this.writable = true;
/**
* Options object
* @public */
this.options = options || {};
/**
* Indicates current state the parser is in
* @private */
this._state = STATES.header;
/**
* The remaining data from the previos chunk which is waiting to be processed
* @private */
this._remainder = "";
/**
* The complete tree structure of the e-mail
* @public */
this.mimeTree = this._createMimeNode();
/**
* Current node of the multipart mime tree that is being processed
* @private */
this._currentNode = this.mimeTree;
// default values for the root node
this._currentNode.priority = "normal";
/**
* An object of already used attachment filenames
* @private */
this._fileNames = {};
/**
* An array of multipart nodes
* @private */
this._multipartTree = [];
/**
* This is the final mail structure object that is returned to the client
* @public */
this.mailData = {};
/**
* Line counter for debugging
* @private */
this._lineCounter = 0;
/**
* Did the last chunk end with \r
* @private */
this._lineFeed = false;
/**
* Is the "headers" event already emitted
* @private */
this._headersSent = false;
/**
* If the e-mail is in mbox format, unescape ">From " to "From " in body
* @private */
this._isMbox = -1;
}
// inherit methods and properties of Stream
utillib.inherits(MailParser, Stream);
/**
* <p>Writes a value to the MailParser stream<p>
*
* @param {Buffer|String} chunk The data to be written to the MailParser stream
* @param {String} [encoding] The encoding to be used when "chunk" is a string
* @returns {Boolean} Returns true
*/
MailParser.prototype.write = function(chunk, encoding) {
if (this._write(chunk, encoding)) {
if (typeof setImmediate == "function") {
setImmediate(this._process.bind(this));
} else {
process.nextTick(this._process.bind(this));
}
}
return true;
};
/**
* <p>Terminates the MailParser stream</p>
*
* <p>If "chunk" is set, writes it to the Stream before terminating.</p>
*
* @param {Buffer|String} chunk The data to be written to the MailParser stream
* @param {String} [encoding] The encoding to be used when "chunk" is a string
*/
MailParser.prototype.end = function(chunk, encoding) {
this._write(chunk, encoding);
if (this.options.debug && this._remainder) {
console.log("REMAINDER: " + this._remainder);
}
if (typeof setImmediate == "function") {
setImmediate(this._process.bind(this, true));
} else {
process.nextTick(this._process.bind(this, true));
}
};
/**
* <p>Normalizes CRLF's before writing to the Mailparser stream, does <i>not</i> call `_process`<p>
*
* @param {Buffer|String} chunk The data to be written to the MailParser stream
* @param {String} [encoding] The encoding to be used when "chunk" is a string
* @returns {Boolean} Returns true if writing the chunk was successful
*/
MailParser.prototype._write = function(chunk, encoding) {
if (typeof chunk == "string") {
chunk = new Buffer.from(chunk, encoding);
}
chunk = chunk && chunk.toString("binary") || "";
// if the last chunk ended with \r and this one begins
// with \n, it's a split line ending. Since the last \r
// was already used, skip the \n
if (this._lineFeed && chunk.charAt(0) === "\n") {
chunk = chunk.substr(1);
}
this._lineFeed = chunk.substr(-1) === "\r";
if (chunk && chunk.length) {
this._remainder += chunk;
return true;
}
return false;
};
/**
* <p>Processes the data written to the MailParser stream</p>
*
* <p>The data is split into lines and each line is processed individually. Last
* line in the batch is preserved as a remainder since it is probably not a
* complete line but just the beginning of it. The remainder is later prepended
* to the next batch of data.</p>
*
* @param {Boolean} [finalPart=false] if set to true indicates that this is the last part of the stream
*/
MailParser.prototype._process = function(finalPart) {
finalPart = !!finalPart;
var lines = this._remainder.split(/\r?\n|\r/),
line, i, len;
if (!finalPart) {
this._remainder = lines.pop();
// force line to 1MB chunks if needed
if (this._remainder.length > 1048576) {
this._remainder = this._remainder.replace(/(.{1048576}(?!\r?\n|\r))/g, "$&\n");
}
}
for (i = 0, len = lines.length; i < len; i++) {
line = lines[i];
if (this.options.unescapeSMTP && line.substr(0, 2) == "..") {
line = line.substr(1);
}
if (this._isMbox === true && line.match(/^\>+From /)) {
line = line.substr(1);
}
if (this.options.debug) {
console.log("LINE " + (++this._lineCounter) + " (" + this._state + "): " + line);
}
if (this._state == STATES.header) {
if (this._processStateHeader(line) === true) {
continue;
}
}
if (this._state == STATES.body) {
if (this._processStateBody(line) === true) {
continue;
}
}
}
if (finalPart) {
if (this._state == STATES.header && this._remainder) {
this._processStateHeader(this._remainder);
if (!this._headersSent) {
this.emit("headers", this._currentNode.parsedHeaders);
this._headersSent = true;
}
}
if (this._currentNode.content || this._currentNode.stream) {
this._finalizeContents();
}
this._state = STATES.finished;
if (typeof setImmediate == "function") {
setImmediate(this._processMimeTree.bind(this));
} else {
process.nextTick(this._processMimeTree.bind(this));
}
}
};
/**
* <p>Processes a line while in header state</p>
*
* <p>If header state ends and body starts, detect if the contents is an attachment
* and create a stream for it if needed</p>
*
* @param {String} line The contents of a line to be processed
* @returns {Boolean} If state changes to body retuns true
*/
MailParser.prototype._processStateHeader = function(line) {
var attachment, lastPos = this._currentNode.headers.length - 1,
textContent = false,
rootNode,
extension;
// Check if the header ends and body starts
if (!line.length) {
if (lastPos >= 0) {
this._processHeaderLine(lastPos);
}
if (!this._headersSent) {
this.emit("headers", this._currentNode.parsedHeaders);
this._headersSent = true;
}
this._state = STATES.body;
// if there's unprocessed header data, do it now
if (lastPos >= 0) {
this._processHeaderLine(lastPos);
}
// this is a very simple e-mail, no content type set
if (!this._currentNode.parentNode && !this._currentNode.meta.contentType) {
this._currentNode.meta.contentType = "text/plain";
}
textContent = ["text/plain", "text/html", "text/calendar"].indexOf(this._currentNode.meta.contentType || "") >= 0;
// detect if this is an attachment or a text node (some agents use inline dispositions for text)
if (textContent && (!this._currentNode.meta.contentDisposition || this._currentNode.meta.contentDisposition == "inline")) {
this._currentNode.attachment = false;
} else if ((!textContent || ["attachment", "inline"].indexOf(this._currentNode.meta.contentDisposition) >= 0) &&
!this._currentNode.meta.mimeMultipart) {
this._currentNode.attachment = true;
}
// handle attachment start
if (this._currentNode.attachment) {
this._currentNode.meta.generatedFileName = this._generateFileName(this._currentNode.meta.fileName, this._currentNode.meta.contentType);
this._currentNode.meta.contentId = this._currentNode.meta.contentId ||
crypto.createHash("md5").update(new Buffer.from(this._currentNode.meta.generatedFileName, 'utf-8')).digest("hex") + "@mailparser";
extension = this._currentNode.meta.generatedFileName.split(".").pop().toLowerCase();
// Update content-type if it's an application/octet-stream and file extension is available
if (this._currentNode.meta.contentType == "application/octet-stream" && mime.getType(extension)) {
this._currentNode.meta.contentType = mime.getType(extension);
}
attachment = this._currentNode.meta;
if (this.options.streamAttachments) {
if (this._currentNode.meta.transferEncoding == "base64") {
this._currentNode.stream = new Streams.Base64Stream();
} else if (this._currentNode.meta.transferEncoding == "quoted-printable") {
this._currentNode.stream = new Streams.QPStream("binary");
} else if (this._currentNode.meta.transferEncoding == "uuencode") {
this._currentNode.stream = new Streams.UUEStream("binary");
} else {
this._currentNode.stream = new Streams.BinaryStream();
}
attachment.stream = this._currentNode.stream;
rootNode = this._currentNode;
while (rootNode.parentNode) {
rootNode = rootNode.parentNode;
}
this.emit("attachment", attachment, rootNode);
} else {
this._currentNode.content = undefined;
}
}
return true;
}
// unfold header lines if needed
if (line.match(/^\s+/) && lastPos >= 0) {
this._currentNode.headers[lastPos] += " " + line.trim();
} else {
this._currentNode.headers.push(line.trim());
if (lastPos >= 0) {
// if a complete header line is received, process it
this._processHeaderLine(lastPos);
}
}
return false;
};
/**
* <p>Processes a line while in body state</p>
*
* @param {String} line The contents of a line to be processed
* @returns {Boolean} If body ends return true
*/
MailParser.prototype._processStateBody = function(line) {
var i, len, node,
nodeReady = false;
// Handle multipart boundaries
if (line.substr(0, 2) == "--") {
for (i = 0, len = this._multipartTree.length; i < len; i++) {
// check if a new element block starts
if (line == "--" + this._multipartTree[i].boundary) {
if (this._currentNode.content || this._currentNode.stream) {
this._finalizeContents();
}
node = this._createMimeNode(this._multipartTree[i].node);
this._multipartTree[i].node.childNodes.push(node);
this._currentNode = node;
this._state = STATES.header;
nodeReady = true;
break;
} else
// check if a multipart block ends
if (line == "--" + this._multipartTree[i].boundary + "--") {
if (this._currentNode.content || this._currentNode.stream) {
this._finalizeContents();
}
if (this._multipartTree[i].node.parentNode) {
this._currentNode = this._multipartTree[i].node.parentNode;
} else {
this._currentNode = this._multipartTree[i].node;
}
this._state = STATES.body;
nodeReady = true;
break;
}
}
}
if (nodeReady) {
return true;
}
// handle text or attachment line
if (["text/plain", "text/html", "text/calendar"].indexOf(this._currentNode.meta.contentType || "") >= 0 &&
!this._currentNode.attachment) {
this._handleTextLine(line);
} else if (this._currentNode.attachment) {
this._handleAttachmentLine(line);
}
return false;
};
/**
* <p>Processes a complete unfolded header line</p>
*
* <p>Processes a line from current node headers array and replaces its value.
* Input string is in the form of "X-Mailer: PHP" and its replacement would be
* an object <code>{key: "x-mailer", value: "PHP"}</code></p>
*
* <p>Additionally node meta object will be filled also, for example with data from
* To: From: Cc: etc fields.</p>
*
* @param {Number} pos Which header element (from an header lines array) should be processed
*/
MailParser.prototype._processHeaderLine = function(pos) {
var key, value, parts, line;
pos = pos || 0;
if (!(line = this._currentNode.headers[pos]) || typeof line != "string") {
return;
}
if (!this._headersSent && this._isMbox < 0) {
if ((this._isMbox = !!line.match(/^From /))) {
return;
}
}
parts = line.split(":");
key = parts.shift().toLowerCase().trim();
value = parts.join(":").trim();
switch (key) {
case "content-type":
this._parseContentType(value);
break;
case "mime-version":
this._currentNode.useMIME = true;
break;
case "date":
this._currentNode.meta.date = this._parseDateString(value);
break;
case "received":
case "x-received":
this._parseReceived(value);
break;
case "to":
if (this._currentNode.to && this._currentNode.to.length) {
this._currentNode.to = this._currentNode.to.concat(parseAddresses(value));
} else {
this._currentNode.to = parseAddresses(value);
}
break;
case "from":
if (this._currentNode.from && this._currentNode.from.length) {
this._currentNode.from = this._currentNode.from.concat(parseAddresses(value));
} else {
this._currentNode.from = parseAddresses(value);
}
break;
case "reply-to":
if (this._currentNode.replyTo && this._currentNode.replyTo.length) {
this._currentNode.replyTo = this._currentNode.replyTo.concat(parseAddresses(value));
} else {
this._currentNode.replyTo = parseAddresses(value);
}
break;
case "cc":
if (this._currentNode.cc && this._currentNode.cc.length) {
this._currentNode.cc = this._currentNode.cc.concat(parseAddresses(value));
} else {
this._currentNode.cc = parseAddresses(value);
}
break;
case "bcc":
if (this._currentNode.bcc && this._currentNode.bcc.length) {
this._currentNode.bcc = this._currentNode.bcc.concat(parseAddresses(value));
} else {
this._currentNode.bcc = parseAddresses(value);
}
break;
case "x-priority":
case "x-msmail-priority":
case "importance":
value = this._parsePriority(value);
this._currentNode.priority = value;
break;
case "message-id":
this._currentNode.meta.messageId = this._trimQuotes(value);
this._currentNode.messageId = this._currentNode.meta.messageId;
break;
case "references":
this._parseReferences(value);
break;
case "in-reply-to":
this._parseInReplyTo(value);
break;
case "thread-index":
this._currentNode.meta.threadIndex = value;
break;
case "content-transfer-encoding":
this._currentNode.meta.transferEncoding = value.toLowerCase();
break;
case "content-location":
this._currentNode.meta.location = value.toLowerCase();
break;
case "subject":
this._currentNode.subject = this._encodeString(value);
break;
case "content-disposition":
this._parseContentDisposition(value);
break;
case "content-id":
this._currentNode.meta.contentId = this._trimQuotes(value);
break;
}
if (this._currentNode.parsedHeaders[key]) {
if (!Array.isArray(this._currentNode.parsedHeaders[key])) {
this._currentNode.parsedHeaders[key] = [this._currentNode.parsedHeaders[key]];
}
this._currentNode.parsedHeaders[key].push(this._replaceMimeWords(value));
} else {
this._currentNode.parsedHeaders[key] = this._replaceMimeWords(value);
}
this._currentNode.headers[pos] = {
key: key,
value: value
};
};
/**
* <p>Creates an empty node element for the mime tree</p>
*
* <p>Created element includes parentNode property and a childNodes array. This is
* needed to later walk the whole mime tree</p>
*
* @param {Object} [parentNode] the parent object for the created node
* @returns {Object} node element for the mime tree
*/
MailParser.prototype._createMimeNode = function(parentNode) {
var node = {
parentNode: parentNode || this._currentNode || null,
headers: [],
parsedHeaders: {},
meta: {},
childNodes: []
};
return node;
};
/**
* <p>Parses date string</o>
*
* <p>Receives possible date string in different formats and
* transforms it into a JS Date object</p>
*
* @param {String} value possible date string
* @returns {Date|Boolean} date object
*/
MailParser.prototype._parseDateString = function(value) {
var date;
date = new Date(value);
if (Object.prototype.toString.call(date) != "[object Date]" || date.toString() == "Invalid Date") {
try {
date = datetime.strtotime(value);
} catch (E) {
return false;
}
if (date) {
date = new Date(date * 1000);
} else {
return false;
}
}
return date;
};
/**
* <p>Parses Received and X-Received header field value</p>
*
* <p>Pulls received date from the received and x-received header fields and
* update current node meta object with this date as long as it's later as the
* existing date of the meta object</p>
*
* <p>Example: <code>by 10.25.25.72 with SMTP id 69csp2404548lfz; Fri, 6 Feb 2015 15:15:32 -0800 (PST)</code>
* will become:
* </p>
*
* <pre>new Date('2015-02-06T23:15:32.000Z')</pre>
*
* @param {String} value Received string
* @returns {Date|Boolean} parsed received date
*/
MailParser.prototype._parseReceived = function(value) {
var receivedDate, date, splitString;
if (!value) {
return false;
}
splitString = value.split(';');
value = splitString[splitString.length - 1];
date = this._parseDateString(value);
receivedDate = this._currentNode.meta.receivedDate;
if (!date) {
if (!receivedDate) {
this._currentNode.meta.receivedDate = date;
}
return date;
}
if (!receivedDate) {
this._currentNode.meta.receivedDate = date;
} else if (date > receivedDate) {
this._currentNode.meta.receivedDate = date;
}
return date;
};
/**
* <p>Parses a Content-Type header field value</p>
*
* <p>Fetches additional properties from the content type (charset etc.) and fills
* current node meta object with this data</p>
*
* @param {String} value Content-Type string
* @returns {Object} parsed contenttype object
*/
MailParser.prototype._parseContentType = function(headerValue) {
const parsed = libmime.parseHeaderValue(headerValue);
if (parsed.value) {
parsed.value = parsed.value.toLowerCase();
this._currentNode.meta.contentType = parsed.value;
if (parsed.value.substr(0, "multipart/".length) == "multipart/") {
this._currentNode.meta.mimeMultipart = parsed.value.substr("multipart/".length);
}
} else {
this._currentNode.meta.contentType = "application/octet-stream";
}
const params = parsed.params;
if (params.charset) {
params.charset = params.charset.toLowerCase();
if (params.charset.substr(0, 4) == "win-") {
params.charset = "windows-" + params.charset.substr(4);
} else if (params.charset == "ks_c_5601-1987") {
params.charset = "cp949";
} else if (params.charset.match(/^utf\d/)) {
params.charset = "utf-" + params.charset.substr(3);
} else if (params.charset.match(/^latin[\-_]?\d/)) {
params.charset = "iso-8859-" + params.charset.replace(/\D/g, "");
} else if (params.charset.match(/^(us\-)?ascii$/)) {
params.charset = "utf-8";
} else if (params.charset.match(/^ansi_x3\.4\-19/)) {
// ANSI_X3.4-1968 and ANSI_X3.4-1986 are aliases for ASCII.
// See http://en.wikipedia.org/wiki/ASCII#Aliases
params.charset = "utf-8";
}
this._currentNode.meta.charset = params.charset;
}
if (params.format) {
this._currentNode.meta.textFormat = params.format.toLowerCase();
}
if (params.delsp) {
this._currentNode.meta.textDelSp = params.delsp.toLowerCase();
}
if (params.boundary) {
this._currentNode.meta.mimeBoundary = params.boundary;
}
if (params.method) {
this._currentNode.meta.method = params.method;
}
let fileName;
if (!this._currentNode.meta.fileName && (fileName = this._detectFilename(params))) {
this._currentNode.meta.fileName = fileName;
}
if (params.boundary) {
this._currentNode.meta.mimeBoundary = params.boundary;
this._multipartTree.push({
boundary: params.boundary,
node: this._currentNode
});
}
};
/**
* <p>Parses file name from a Content-Type or Content-Disposition field</p>
*
* <p>Supports <a href="http://tools.ietf.org/html/rfc2231">RFC2231</a> for
* folded filenames</p>
*
* @param {Object} value Parsed Content-(Type|Disposition) object
* @return {String} filename
*/
MailParser.prototype._detectFilename = function(params) {
if (params.name) {
return this._replaceMimeWords(params.name);
}
if (params.filename) {
return this._replaceMimeWords(params.filename);
}
return "";
};
/**
* <p>Parses Content-Disposition header field value</p>
*
* <p>Fetches filename to current node meta object</p>
*
* @param {String} value A Content-Disposition header field
*/
MailParser.prototype._parseContentDisposition = function(value) {
const parsed = libmime.parseHeaderValue(value);
if (parsed.value) {
this._currentNode.meta.contentDisposition = parsed.value.trim().toLowerCase();
}
let fileName;
if ((fileName = this._detectFilename(parsed.params))) {
this._currentNode.meta.fileName = fileName;
}
};
/**
* <p>Parses "References" header</p>
*
* @param {String} value References header field
*/
MailParser.prototype._parseReferences = function(value) {
this._currentNode.references = (this._currentNode.references || []).concat(
(value || "").toString().trim().split(/\s+/).map(this._trimQuotes.bind(this))
);
};
/**
* <p>Parses "In-Reply-To" header</p>
*
* @param {String} value In-Reply-To header field
*/
MailParser.prototype._parseInReplyTo = function(value) {
this._currentNode.inReplyTo = (this._currentNode.inReplyTo || []).concat(
(value || "").toString().trim().split(/\s+/).map(this._trimQuotes.bind(this))
);
};
/**
* <p>Parses the priority of the e-mail</p>
*
* @param {String} value The priority value
* @returns {String} priority string low|normal|high
*/
MailParser.prototype._parsePriority = function(value) {
value = value.toLowerCase().trim();
if (!isNaN(parseInt(value, 10))) { // support "X-Priority: 1 (Highest)"
value = parseInt(value, 10) || 0;
if (value == 3) {
return "normal";
} else if (value > 3) {
return "low";
} else {
return "high";
}
} else {
switch (value) {
case "non-urgent":
case "low":
return "low";
case "urgent":
case "high":
return "high";
}
}
return "normal";
};
/**
* <p>Processes a line in text/html or text/plain node</p>
*
* <p>Append the line to the content property</p>
*
* @param {String} line A line to be processed
*/
MailParser.prototype._handleTextLine = function(line) {
if (["quoted-printable", "base64"].indexOf(this._currentNode.meta.transferEncoding) >= 0 || this._currentNode.meta.textFormat != "flowed") {
if (typeof this._currentNode.content != "string") {
this._currentNode.content = line;
} else {
this._currentNode.content += "\n" + line;
}
} else {
if (typeof this._currentNode.content != "string") {
this._currentNode.content = line;
} else if (this._currentNode.content.match(/[ ]$/)) {
if (this._currentNode.meta.textFormat == "flowed" && this._currentNode.content.match(/(^|\n)-- $/)) {
// handle special case for usenet signatures
this._currentNode.content += "\n" + line;
} else {
if (this._currentNode.meta.textDelSp == "yes") {
this._currentNode.content = this._currentNode.content.replace(/[ ]+$/, "");
}
this._currentNode.content += line;
}
} else {
this._currentNode.content += "\n" + line;
}
}
};
/**
* <p>Processes a line in an attachment node</p>
*
* <p>If a stream is set up for the attachment write the line to the
* stream as a Buffer object, otherwise append it to the content property</p>
*
* @param {String} line A line to be processed
*/
MailParser.prototype._handleAttachmentLine = function(line) {
if (!this._currentNode.attachment) {
return;
}
if (this._currentNode.stream) {
if (!this._currentNode.streamStarted) {
this._currentNode.streamStarted = true;
this._currentNode.stream.write(new Buffer.from(line, "binary"));
} else {
this._currentNode.stream.write(new Buffer.from("\r\n" + line, "binary"));
}
} else if ("content" in this._currentNode) {
if (typeof this._currentNode.content != "string") {
this._currentNode.content = line;
} else {
this._currentNode.content += "\r\n" + line;
}
}
};
/**
* <p>Finalizes a node processing</p>
*
* <p>If the node is a text/plain or text/html, convert it to UTF-8 encoded string
* If it is an attachment, convert it to a Buffer or if an attachment stream is
* set up, close the stream</p>
*/
MailParser.prototype._finalizeContents = function() {
var streamInfo;
if (this._currentNode.content) {
if (!this._currentNode.attachment) {
if (this._currentNode.meta.contentType == "text/html" && !this._currentNode.meta.charset) {
this._currentNode.meta.charset = this._detectHTMLCharset(this._currentNode.content) || this.options.defaultCharset || "iso-8859-1";
}
if (this._currentNode.meta.transferEncoding == "quoted-printable") {
const temp = libqp.decode(this._currentNode.content);
this._currentNode.content = this._convertStringToUTF8(temp);
if (this._currentNode.meta.textFormat == "flowed") {
if (this._currentNode.meta.textDelSp == "yes") {
this._currentNode.content = this._currentNode.content.replace(/(^|\n)-- \n/g, '$1-- \u0000').replace(/ \n/g, '').replace(/(^|\n)-- \u0000/g, '$1-- \n');
} else {
this._currentNode.content = this._currentNode.content.replace(/(^|\n)-- \n/g, '$1-- \u0000').replace(/ \n/g, ' ').replace(/(^|\n)-- \u0000/g, '$1-- \n');
}
}
} else if (this._currentNode.meta.transferEncoding == "base64") {
const temp = new Buffer.from(this._currentNode.content.toString().replace(/\s+/g, ""), "base64");
this._currentNode.content = this._convertStringToUTF8(temp);
} else {
this._currentNode.content = this._convertStringToUTF8(this._currentNode.content);
}
} else {
if (this._currentNode.meta.transferEncoding == "quoted-printable") {
this._currentNode.content = libqp.decode(this._currentNode.content);
} else if (this._currentNode.meta.transferEncoding == "base64") {
// WTF? if newlines are not removed, the resulting hash is *always* different
this._currentNode.content = new Buffer.from(this._currentNode.content.toString().replace(/\s+/g, ""), "base64");
} else if (this._currentNode.meta.transferEncoding == "uuencode") {
var uuestream = new Streams.UUEStream("binary");
this._currentNode.content = uuestream.decode(new Buffer.from(this._currentNode.content, "binary"));
} else {
this._currentNode.content = new Buffer.from(this._currentNode.content, "binary");
}
this._currentNode.checksum = crypto.createHash("md5");
this._currentNode.checksum.update(this._currentNode.content);
this._currentNode.meta.checksum = this._currentNode.checksum.digest("hex");
this._currentNode.meta.length = this._currentNode.content.length;
}
}
if (this._currentNode.stream) {
streamInfo = this._currentNode.stream.end() || {};
if (streamInfo.checksum) {
this._currentNode.meta.checksum = streamInfo.checksum;
}
if (streamInfo.length) {
this._currentNode.meta.length = streamInfo.length;
}
}
};
/**
* <p>Processes the mime tree</p>
*
* <p>Finds text parts and attachments from the tree. If there's several text/plain
* or text/html parts, join these into one</p>
*
* <p>Emits "end" when finished</p>
*/
MailParser.prototype._processMimeTree = function() {
var returnValue = {},
i, len;
this.mailData = {
html: [],
text: [],
calendar: [],
attachments: []
};
if (!this.mimeTree.meta.mimeMultipart) {
this._processMimeNode(this.mimeTree, 0);
} else {
this._walkMimeTree(this.mimeTree);
}
if (this.mailData.html.length) {
for (i = 0, len = this.mailData.html.length; i < len; i++) {
if (!returnValue.html && this.mailData.html[i].content) {
returnValue.html = this.mailData.html[i].content;
} else if (this.mailData.html[i].content) {
returnValue.html = this._concatHTML(returnValue.html, this.mailData.html[i].content);
}
}
}
if (this.mailData.text.length) {
var len = this.mailData.text.length;
// if we have both html and text, process text till the length of html assuming its alternative for html
if (this.mailData.html.length) {
len = Math.min(len, this.mailData.html.length);
}
for (i = 0, len; i < len; i++) {
if (!returnValue.text && this.mailData.text[i].content) {
returnValue.text = this.mailData.text[i].content;
} else if (this.mailData.text[i].content) {
returnValue.text += this.mailData.text[i].content;
}
}
// all remaining text contents if present assumed as additional content and concatenated with html as well as text
for (len = this.mailData.text.length; i < len; i++) {
if (this.mailData.text[i].content) {
// concatenate to both text and html so that text and html are always same content
// user should be able to chose any one of them
returnValue.text += this.mailData.text[i].content;
returnValue.html += this.mailData.text[i].content;
}
}
}
if (this.mailData.calendar.length) {
returnValue.alternatives = [];
for (i = 0, len = this.mailData.calendar.length; i < len; i++) {
returnValue.alternatives.push(this.mailData.calendar[i].content);
}
}
returnValue.headers = this.mimeTree.parsedHeaders;
if (this.mimeTree.subject) {
returnValue.subject = this.mimeTree.subject;
}
if (this.mimeTree.references) {
returnValue.references = this.mimeTree.references;
}
if (this.mimeTree.messageId) {
returnValue.messageId = this.mimeTree.messageId;
}
if (this.mimeTree.inReplyTo) {
returnValue.inReplyTo = this.mimeTree.inReplyTo;
}
if (this.mimeTree.priority) {
returnValue.priority = this.mimeTree.priority;
}
if (this.mimeTree.from) {
returnValue.from = this.mimeTree.from;
}
if (this.mimeTree.replyTo) {
returnValue.replyTo = this.mimeTree.replyTo;
}
if (this.mimeTree.to) {
returnValue.to = this.mimeTree.to;
}
if (this.mimeTree.cc) {
returnValue.cc = this.mimeTree.cc;
}
if (this.mimeTree.bcc) {
returnValue.bcc = this.mimeTree.bcc;
}
if (this.mimeTree.meta.date) {
returnValue.date = this.mimeTree.meta.date;
}
if (this.mimeTree.meta.receivedDate) {
returnValue.receivedDate = this.mimeTree.meta.receivedDate;
}
if (this.mailData.attachments.length) {
returnValue.attachments = [];
for (i = 0, len = this.mailData.attachments.length; i < len; i++) {
returnValue.attachments.push(this.mailData.attachments[i].content);
}
}
if (typeof setImmediate == "function") {
setImmediate(this.emit.bind(this, "end", returnValue));
} else {
process.nextTick(this.emit.bind(this, "end", returnValue));
}
};
/**
* <p>Walks the mime tree and runs processMimeNode on each node of the tree</p>
*
* @param {Object} node A mime tree node
* @param {Number} [level=0] current depth
*/
MailParser.prototype._walkMimeTree = function(node, level) {
level = level || 1;
for (var i = 0, len = node.childNodes.length; i < len; i++) {
this._processMimeNode(node.childNodes[i], level, node.meta.mimeMultipart);
this._walkMimeTree(node.childNodes[i], level + 1);
}
};
/**
* <p>Processes of a node in the mime tree</p>
*
* <p>Pushes the node into appropriate <code>this.mailData</code> array (<code>text/html</code> to <code>this.mailData.html</code> array etc)</p>
*
* @param {Object} node A mime tree node
* @param {Number} [level=0] current depth
* @param {String} mimeMultipart Type of multipart we are dealing with (if any)
*/
MailParser.prototype._processMimeNode = function(node, level, mimeMultipart) {
var i, len;
level = level || 0;
if (!node.attachment) {
switch (node.meta.contentType) {
case "text/html":
if (mimeMultipart == "mixed" && this.mailData.html.length) {
for (i = 0, len = this.mailData.html.length; i < len; i++) {
if (this.mailData.html[i].level == level) {
this._joinHTMLNodes(this.mailData.html[i], node.content);
return;
}
}
}
this.mailData.html.push({
content: this._updateHTMLCharset(node.content || ""),
level: level
});
return;
case "text/plain":
this.mailData.text.push({
content: node.content || "",
level: level
});
return;
case "text/calendar":
if (node.content) {
node.meta.content = node.content;
}
this.mailData.calendar.push({
content: node.meta || {},
level: level
});
return;
}
} else {
node.meta = node.meta || {};
if (node.content) {
node.meta.content = node.content;
}
this.mailData.attachments.push({
content: node.meta || {},
level: level
});
if (this.options.showAttachmentLinks && mimeMultipart == "mixed" && this.mailData.html.length) {
for (i = 0, len = this.mailData.html.length; i < len; i++) {
if (this.mailData.html[i].level == level) {
this._joinHTMLAttachment(this.mailData.html[i], node.meta);
return;
}
}
}
}
};
/**
* <p>Joins two HTML blocks by removing the header of the added element<p>
*
* @param {Object} htmlNode Original HTML contents node object
* @param {String} newHTML HTML text to add to the original object node
*/
MailParser.prototype._joinHTMLNodes = function(htmlNode, newHTML) {
var inserted = false;
// process new HTML
newHTML = (newHTML || "").toString("utf-8").trim();
// remove doctype from the beginning
newHTML = newHTML.replace(/^\s*<\!doctype( [^>]*)?>/gi, "");
// remove <head> and <html> blocks
newHTML = newHTML.replace(/<head( [^>]*)?>(.*)<\/head( [^>]*)?>/gi, "").
replace(/<\/?html( [^>]*)?>/gi, "").
trim();
// keep only text between <body> tags (if <body exists)
newHTML.replace(/<body(?: [^>]*)?>(.*)<\/body( [^>]*)?>/gi, function(match, body) {
newHTML = body.trim();
});
htmlNode.content = (htmlNode.content || "").toString("utf-8").trim();
htmlNode.content = htmlNode.content.replace(/<\/body( [^>]*)?>/i, function(match) {
inserted = true;
return "<br/>\n" + newHTML + match;
});
if (!inserted) {
htmlNode.content += "<br/>\n" + newHTML;
}
};
/**
* <p>Adds filename placeholder to the HTML if needed</p>
*
* @param {Object} htmlNode Original HTML contents node object
* @param {String} attachment Attachment meta object
*/
MailParser.prototype._joinHTMLAttachment = function(htmlNode, attachment) {
var inserted = false,
fname = attachment.generatedFileName.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """),
newHTML;
newHTML = "\n<div class=\"mailparser-attachment\"><a href=\"cid:" + attachment.contentId + "\"><" + fname + "></a></div>";
htmlNode.content = (htmlNode.content || "").toString("utf-8").trim();
htmlNode.content = htmlNode.content.replace(/<\/body\b[^>]*>/i, function(match) {
inserted = true;
return "<br/>\n" + newHTML + match;
});
if (!inserted) {
htmlNode.content += "<br/>\n" + newHTML;
}
};
/**
* <p>Joins two HTML blocks by removing the header of the added element<p>
*
* @param {Sting} htmlNode Original HTML contents
* @param {String} newHTML HTML text to add to the original object node
* @return {String} Joined HTML
*/
MailParser.prototype._concatHTML = function(firstNode, secondNode) {
var headerNode = "",
htmlHeader = "";
firstNode = (firstNode || "").toString("utf-8");
secondNode = (secondNode || "").toString("utf-8");
if (!secondNode) {
return firstNode;
}
if (!firstNode) {
return secondNode;
}
if (firstNode.substr(0, 1024).replace(/\r?\n/g, "\u0000").match(/^[\s\u0000]*(<\!doctype\b[^>]*?>)?[\s\u0000]*<(html|head)\b[^>]*?>/i)) {
headerNode = firstNode;
} else if (secondNode.substr(0, 1024).replace(/\r?\n/g, "\u0000").match(/^[\s\u0000]*(<\!doctype\b[^>]*?>)?[\s\u0000]*<(html|head)\b[^>]*?>/i)) {
headerNode = secondNode;
}
if (headerNode) {
headerNode.replace(/\r?\n/g, "\u0000").replace(/^[\s\u0000]*(<\!doctype\b[^>]*?>)?[\s\u0000]*<(html|head)\b[^>]*>.*?<\/(head)\b[^>]*>(.*?<body\b[^>]*>)?/i, function(h) {
var doctype = h.match(/^[\s\u0000]*(<\!doctype\b[^>]*?>)/i),
html = h.match(/<html\b[^>]*?>/i),
head = h.match(/<head\b[^>]*?>/i),
body = h.match(/<body\b[^>]*?>/i);
doctype = doctype && doctype[1] && doctype[1] + "\n" || "";
html = html && html[0] || "<head>";
head = head && head[0] || "<head>";
body = body && body[0] || "<body>";
h = h.replace(/<[\!\/]?(doctype|html|head|body)\b[^>]*?>/ig, "\u0000").replace(/\u0000+/g, "\n").trim();
htmlHeader = doctype + html + "\n" + head + (h ? h + "\n" : "") + "</head>\n" + body + "\n";
});
}
firstNode = firstNode.replace(/\r?\n/g, "\u0000").
replace(/[\s\u0000]*<head\b[^>]*>.*?<\/(head|body)\b[^>]*>/gi, "").
replace(/[\s\u0000]*<[\!\/]?(doctype|html|body)\b[^>]*>[\s\u0000]*/gi, "").
replace(/\u0000/g, "\n");
secondNode = secondNode.replace(/\r?\n/g, "\u0000").
replace(/[\s\u0000]*<head\b[^>]*>.*?<\/(head|body)\b[^>]*>/gi, "").
replace(/[\s\u0000]*<[\!\/]?(doctype|html|body)\b[^>]*>[\s\u0000]*/gi, "").
replace(/\u0000/g, "\n");
return htmlHeader + firstNode + secondNode + (htmlHeader ? (firstNode || secondNode ? "\n" : "") + "</body>\n</html>" : "");
};
/**
* <p>Converts a string to UTF-8</p>
*
* @param {String} value String to be encoded
* @returns {String} UTF-8 encoded string
*/
MailParser.prototype._convertStringToUTF8 = function(value) {
value = typeof value == 'string' ? Buffer.from(value, 'binary') : value;
const from = this._currentNode.meta.charset || this.options.defaultCharset || 'iso-8859-1';
const output = charset.decode(value, from);
return output;
};
/**
* <p>Encodes a header string to UTF-8</p>
*
* @param {String} value String to be encoded
* @returns {String} UTF-8 encoded string
*/
MailParser.prototype._encodeString = function(value) {
value = this._replaceMimeWords(this._convertStringToUTF8(value));
return value;
};
/**
* <p>Replaces mime words in a string with UTF-8 encoded strings</p>
*
* @param {String} value String to be converted
* @returns {String} converted string
*/
MailParser.prototype._replaceMimeWords = function(value) {
return value.
replace(/(=\?[^?]+\?[QqBb]\?[^?]*\?=)\s+(?==\?[^?]+\?[QqBb]\?[^?]*\?=)/g, "$1"). // join mimeWords
replace(/\=\?[^?]+\?[QqBb]\?[^?]*\?=/g, (function(a) {
return libmime.decodeWord(a.replace(/\s/g, ''));
}).bind(this));
};
/**
* <p>Removes enclosing quotes ("", '', <>) from a string</p>
*
* @param {String} value String to be converted
* @returns {String} converted string
*/
MailParser.prototype._trimQuotes = function(value) {
value = (value || "").trim();
if ((value.charAt(0) == '"' && value.charAt(value.length - 1) == '"') ||
(value.charAt(0) == "'" && value.charAt(value.length - 1) == "'") ||
(value.charAt(0) == "<" && value.charAt(value.length - 1) == ">")) {
value = value.substr(1, value.length - 2);
}
return value;
};
/**
* <p>Generates a context unique filename for an attachment</p>
*
* <p>If a filename already exists, append a number to it</p>
*
* <ul>
* <li>file.txt</li>
* <li>file-1.txt</li>
* <li>file-2.txt</li>
* </ul>
*
* @param {String} fileName source filename
* @param {String} contentType source content type
* @returns {String} generated filename
*/
MailParser.prototype._generateFileName = function(fileName, contentType) {
var ext, defaultExt = "",
fileRootName;
if (contentType) {
defaultExt = mime.getExtension(contentType);
defaultExt = defaultExt ? "." + defaultExt : "";
}
fileName = fileName || "attachment" + defaultExt;
// remove path if it is included in the filename
fileName = fileName.toString().split(/[\/\\]+/).pop().replace(/^\.+/, "") || "attachment";
fileRootName = fileName.replace(/(?:\-\d+)+(\.[^.]*)$/, "$1") || "attachment";
if (fileRootName in this._fileNames) {
this._fileNames[fileRootName]++;
ext = fileName.substr((fileName.lastIndexOf(".") || 0) + 1);
if (ext == fileName) {
fileName += "-" + this._fileNames[fileRootName];
} else {
fileName = fileName.substr(0, fileName.length - ext.length - 1) + "-" + this._fileNames[fileRootName] + "." + ext;
}
} else {
this._fileNames[fileRootName] = 0;
}
return fileName;
};
/**
* <p>Replaces character set to UTF-8 in HTML <meta> tags</p>
*
* @param {String} HTML html contents
* @returns {String} updated HTML
*/
MailParser.prototype._updateHTMLCharset = function(html) {
html = html.replace(/\n/g, "\u0000").
replace(/<meta[^>]*>/gi, function(meta) {
if (meta.match(/http\-equiv\s*=\s*"?content\-type/i)) {
return '<meta http-equiv="content-type" content="text/html; charset=utf-8" />';
}
if (meta.match(/\scharset\s*=\s*['"]?[\w\-]+["'\s>\/]/i)) {
return '<meta charset="utf-8"/>';
}
return meta;
}).
replace(/\u0000/g, "\n");
return html;
};
/**
* <p>Detects the charset of an HTML file</p>
*
* @param {String} HTML html contents
* @returns {String} Charset for the HTML
*/
MailParser.prototype._detectHTMLCharset = function(html) {
var charset, input, meta;
if (typeof html != "string") {
html = html.toString("ascii");
}
if ((meta = html.match(/<meta\s+http-equiv=["']content-type["'][^>]*?>/i))) {
input = meta[0];
}
if (input) {
charset = input.match(/charset\s?=\s?([a-zA-Z\-_:0-9]*);?/);
if (charset) {
charset = (charset[1] || "").trim().toLowerCase();
}
}
if (!charset && (meta = html.match(/<meta\s+charset=["']([^'"<\/]*?)["']/i))) {
charset = (meta[1] || "").trim().toLowerCase();
}
return charset;
};