mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-11-11 10:09:20 +08:00
330 lines
11 KiB
JavaScript
330 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
const addressparser = require('nodemailer/lib/addressparser');
|
|
|
|
/**
|
|
* Parses a RFC822 message into a structured object (JSON compatible)
|
|
*
|
|
* @constructor
|
|
* @param {String|Buffer} rfc822 Raw body of the message
|
|
*/
|
|
class MIMEParser {
|
|
constructor(rfc822) {
|
|
// ensure the input is a binary string
|
|
this.rfc822 = (rfc822 || '').toString('binary');
|
|
|
|
this._br = '';
|
|
this._pos = 0;
|
|
|
|
this.rawBody = '';
|
|
|
|
this.tree = {
|
|
rootNode: true,
|
|
childNodes: []
|
|
};
|
|
this._node = this.createNode(this.tree);
|
|
}
|
|
|
|
/**
|
|
* Parses the message, line by line
|
|
*/
|
|
parse() {
|
|
let line,
|
|
prevBr = '';
|
|
|
|
// keep parsing until the last linebreak is not a string (no linebreaks anymore)
|
|
while (typeof this._br === 'string') {
|
|
line = this.readLine();
|
|
|
|
switch (this._node.state) {
|
|
case 'header': // process header section
|
|
if (this.rawBody) {
|
|
this.rawBody += prevBr + line;
|
|
}
|
|
|
|
if (!line) {
|
|
this.processNodeHeader();
|
|
this.processContentType();
|
|
|
|
this._node.state = 'body';
|
|
} else {
|
|
this._node.header.push(line);
|
|
}
|
|
break;
|
|
|
|
case 'body': // process body section
|
|
this.rawBody += prevBr + line;
|
|
|
|
if (this._node.parentBoundary && (line === '--' + this._node.parentBoundary || line === '--' + this._node.parentBoundary + '--')) {
|
|
if (
|
|
this._node.parsedHeader['content-type'].value === 'message/rfc822' &&
|
|
(!this._node.parsedHeader['content-transfer-encoding'] ||
|
|
['7bit', '8bit', 'binary'].includes(this._node.parsedHeader['content-transfer-encoding']))
|
|
) {
|
|
this._node.message = parse(this._node.body.join(''));
|
|
}
|
|
|
|
if (line === '--' + this._node.parentBoundary) {
|
|
this._node = this.createNode(this._node.parentNode);
|
|
} else {
|
|
this._node = this._node.parentNode;
|
|
}
|
|
} else if (this._node.boundary && line === '--' + this._node.boundary) {
|
|
this._node = this.createNode(this._node);
|
|
} else {
|
|
// push the line with previous linebreak value
|
|
// if the array is joined together to a one string,
|
|
// then the linebreaks in the string are the 'original' ones
|
|
this._node.body.push((this._node.body.length ? prevBr : '') + line);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// never should be reached
|
|
throw new Error('Unexpected state');
|
|
}
|
|
|
|
// store the linebreak for later usage
|
|
prevBr = this._br;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads a line from the message body
|
|
*
|
|
* @return {String|Boolean} A line from the message
|
|
*/
|
|
readLine() {
|
|
let match = this.rfc822.substr(this._pos).match(/(.*?)(\r*\n|$)/);
|
|
if (match) {
|
|
this._br = match[2] || false;
|
|
this._pos += match[0].length;
|
|
return match[1];
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Join body arrays into strings. Removes unnecessary fields
|
|
* from the tree (circular references prohibit conversion to JSON)
|
|
*/
|
|
finalizeTree() {
|
|
if (this._node.state === 'header') {
|
|
this.processNodeHeader();
|
|
this.processContentType();
|
|
}
|
|
|
|
if (this.tree.parsedHeader && this.tree.parsedHeader['content-type'].value === 'message/rfc822') {
|
|
this.tree.message = parse(this.tree.body.join(''));
|
|
}
|
|
|
|
let walker = node => {
|
|
if (node.body) {
|
|
if (node.parentNode === this.tree && node.parsedHeader['content-type'].value === 'message/rfc822') {
|
|
node.message = parse(node.body.join(''));
|
|
}
|
|
|
|
node.lineCount = node.body.length ? node.body.length - 1 : 0;
|
|
node.body = Buffer.from(
|
|
node.body
|
|
.join('')
|
|
// ensure proper line endings
|
|
.replace(/\r?\n/g, '\r\n'),
|
|
'binary'
|
|
);
|
|
node.size = node.body.length;
|
|
}
|
|
node.childNodes.forEach(walker);
|
|
|
|
// remove unneeded properties
|
|
delete node.parentNode;
|
|
delete node.state;
|
|
if (!node.childNodes.length) {
|
|
delete node.childNodes;
|
|
}
|
|
delete node.parentBoundary;
|
|
};
|
|
walker(this.tree);
|
|
}
|
|
|
|
/**
|
|
* Creates a new node with default values for the parse tree
|
|
*/
|
|
createNode(parentNode) {
|
|
let node = {
|
|
state: 'header',
|
|
childNodes: [],
|
|
header: [],
|
|
parsedHeader: {},
|
|
body: [],
|
|
multipart: false,
|
|
parentBoundary: parentNode.boundary,
|
|
boundary: false,
|
|
parentNode
|
|
};
|
|
parentNode.childNodes.push(node);
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Processes header lines. Splits lines to key-value pairs
|
|
* and processes special values
|
|
*/
|
|
processNodeHeader() {
|
|
let key, value;
|
|
|
|
for (let i = this._node.header.length - 1; i >= 0; i--) {
|
|
if (i && this._node.header[i].match(/^\s/)) {
|
|
this._node.header[i - 1] = this._node.header[i - 1] + '\r\n' + this._node.header[i];
|
|
this._node.header.splice(i, 1);
|
|
} else {
|
|
value = this._node.header[i].split(':');
|
|
key = (value.shift() || '').trim().toLowerCase();
|
|
value = value.join(':').trim();
|
|
|
|
// Do not touch headers that have strange looking keys, keep these
|
|
// only in the unparsed array
|
|
if (/[^a-zA-Z0-9\-*]/.test(key) || key.length >= 100) {
|
|
continue;
|
|
}
|
|
|
|
// assume UTF-8 for binary headers
|
|
value = Buffer.from(value, 'binary').toString();
|
|
|
|
if (key in this._node.parsedHeader) {
|
|
if (Array.isArray(this._node.parsedHeader[key])) {
|
|
this._node.parsedHeader[key].unshift(value);
|
|
} else {
|
|
this._node.parsedHeader[key] = [value, this._node.parsedHeader[key]];
|
|
}
|
|
} else {
|
|
this._node.parsedHeader[key] = value.replace(/\s*\r?\n\s*/g, ' ');
|
|
}
|
|
}
|
|
}
|
|
|
|
// always ensure the presence of Content-Type
|
|
if (!this._node.parsedHeader['content-type']) {
|
|
this._node.parsedHeader['content-type'] = 'text/plain';
|
|
}
|
|
|
|
// parse additional params for Content-Type and Content-Disposition
|
|
['content-type', 'content-disposition'].forEach(key => {
|
|
if (this._node.parsedHeader[key]) {
|
|
this._node.parsedHeader[key] = this.parseValueParams([].concat(this._node.parsedHeader[key] || []).pop());
|
|
}
|
|
});
|
|
|
|
// ensure single value for selected fields
|
|
['content-transfer-encoding', 'content-id', 'content-description', 'content-language', 'content-md5', 'content-location'].forEach(key => {
|
|
if (Array.isArray(this._node.parsedHeader[key])) {
|
|
this._node.parsedHeader[key] = this._node.parsedHeader[key].pop();
|
|
}
|
|
});
|
|
|
|
// Parse address fields (join several fields with same key)
|
|
['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].forEach(key => {
|
|
let addresses = [];
|
|
if (this._node.parsedHeader[key]) {
|
|
[].concat(this._node.parsedHeader[key] || []).forEach(value => {
|
|
if (value) {
|
|
addresses = addresses.concat(addressparser(value) || []);
|
|
}
|
|
});
|
|
this._node.parsedHeader[key] = addresses;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Splits a value to an object.
|
|
* eg. 'text/plain; charset=utf-8' -> {value: 'text/plain', params:{charset: 'utf-8'}}
|
|
*
|
|
* @param {String} headerValue A string value for a header key
|
|
* @return {Object} Parsed value
|
|
*/
|
|
parseValueParams(headerValue) {
|
|
let data = {
|
|
value: '',
|
|
type: '',
|
|
subtype: '',
|
|
params: {}
|
|
};
|
|
let match;
|
|
let processEncodedWords = {};
|
|
|
|
(headerValue || '').split(';').forEach((part, i) => {
|
|
let key, value;
|
|
if (!i) {
|
|
data.value = part.trim();
|
|
data.subtype = data.value.split('/');
|
|
data.type = (data.subtype.shift() || '').toLowerCase();
|
|
data.subtype = data.subtype.join('/');
|
|
return;
|
|
}
|
|
value = part.split('=');
|
|
key = (value.shift() || '').trim().toLowerCase();
|
|
value = value.join('=').replace(/^['"\s]*|['"\s]*$/g, '');
|
|
|
|
// Do not touch headers that have strange looking keys, keep these
|
|
// only in the unparsed array
|
|
if (/[^a-zA-Z0-9\-*]/.test(key) || key.length >= 100) {
|
|
return;
|
|
}
|
|
|
|
// This regex allows for an optional trailing asterisk, for headers
|
|
// which are encoded with lang/charset info as well as a continuation.
|
|
// See https://tools.ietf.org/html/rfc2231 section 4.1.
|
|
if ((match = key.match(/^([^*]+)\*(\d)?\*?$/))) {
|
|
if (!processEncodedWords[match[1]]) {
|
|
processEncodedWords[match[1]] = [];
|
|
}
|
|
processEncodedWords[match[1]][Number(match[2]) || 0] = value;
|
|
} else {
|
|
data.params[key] = value;
|
|
}
|
|
data.hasParams = true;
|
|
});
|
|
|
|
// convert extended mime word into a regular one
|
|
Object.keys(processEncodedWords).forEach(key => {
|
|
let charset = '';
|
|
let value = '';
|
|
processEncodedWords[key].forEach(val => {
|
|
let parts = val.split("'"); // eslint-disable-line quotes
|
|
charset = charset || parts.shift();
|
|
value += (parts.pop() || '').replace(/%/g, '=');
|
|
});
|
|
data.params[key] = '=?' + (charset || 'ISO-8859-1').toUpperCase() + '?Q?' + value + '?=';
|
|
});
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Checks Content-Type value for the current tree node.
|
|
*/
|
|
processContentType() {
|
|
if (!this._node.parsedHeader['content-type']) {
|
|
return;
|
|
}
|
|
|
|
if (this._node.parsedHeader['content-type'].type === 'multipart' && this._node.parsedHeader['content-type'].params.boundary) {
|
|
this._node.multipart = this._node.parsedHeader['content-type'].subtype;
|
|
this._node.boundary = this._node.parsedHeader['content-type'].params.boundary;
|
|
}
|
|
}
|
|
}
|
|
|
|
function parse(rfc822) {
|
|
let parser = new MIMEParser(rfc822);
|
|
let response;
|
|
|
|
parser.parse();
|
|
parser.finalizeTree();
|
|
|
|
response = parser.tree.childNodes[0] || false;
|
|
return response;
|
|
}
|
|
|
|
module.exports = parse;
|