Do not explode on messages that have a rfc822 message as an attachment

This commit is contained in:
Andris Reinman 2017-06-01 16:55:57 +03:00
parent 6773863038
commit 9534594a7e
7 changed files with 1250 additions and 980 deletions

View file

@ -4,7 +4,6 @@ const libmime = require('libmime');
const createEnvelope = require('./create-envelope');
class BodyStructure {
constructor(tree, options) {
this.tree = tree;
this.options = options || {};
@ -38,13 +37,10 @@ class BodyStructure {
case 'text':
return this.processTextNode(node, options);
case 'message':
if (node.parsedHeader['content-type'].subtype === 'rfc822') {
if (!options.attachmentRFC822) {
return this.processRFC822Node(node, options);
}
return this.processAttachmentNode(node, options);
if (node.parsedHeader['content-type'].subtype === 'rfc822' && node.message && !options.attachmentRFC822) {
return this.processRFC822Node(node, options);
}
// fall through
// fall through
default:
return this.processAttachmentNode(node, options);
}
@ -60,32 +56,32 @@ class BodyStructure {
* @return {Array} A list of basic fields
*/
getBasicFields(node, options) {
let bodyType = node.parsedHeader['content-type'] && node.parsedHeader['content-type'].type || null;
let bodySubtype = node.parsedHeader['content-type'] && node.parsedHeader['content-type'].subtype || null;
let bodyType = (node.parsedHeader['content-type'] && node.parsedHeader['content-type'].type) || null;
let bodySubtype = (node.parsedHeader['content-type'] && node.parsedHeader['content-type'].subtype) || null;
let contentTransfer = node.parsedHeader['content-transfer-encoding'] || '7bit';
return [
// body type
options.upperCaseKeys ? bodyType && bodyType.toUpperCase() || null : bodyType,
options.upperCaseKeys ? (bodyType && bodyType.toUpperCase()) || null : bodyType,
// body subtype
options.upperCaseKeys ? bodySubtype && bodySubtype.toUpperCase() || null : bodySubtype,
options.upperCaseKeys ? (bodySubtype && bodySubtype.toUpperCase()) || null : bodySubtype,
// body parameter parenthesized list
node.parsedHeader['content-type'] &&
node.parsedHeader['content-type'].hasParams &&
this.flatten(Object.keys(node.parsedHeader['content-type'].params).map(key => {
let value = node.parsedHeader['content-type'].params[key];
try {
value = Buffer.from(libmime.decodeWords(value).trim());
} catch (E) {
// failed to parse value
}
return [
options.upperCaseKeys ? key.toUpperCase() : key,
value
];
})) || null,
(node.parsedHeader['content-type'] &&
node.parsedHeader['content-type'].hasParams &&
this.flatten(
Object.keys(node.parsedHeader['content-type'].params).map(key => {
let value = node.parsedHeader['content-type'].params[key];
try {
value = Buffer.from(libmime.decodeWords(value).trim());
} catch (E) {
// failed to parse value
}
return [options.upperCaseKeys ? key.toUpperCase() : key, value];
})
)) ||
null,
// body id
node.parsedHeader['content-id'] || null,
@ -94,7 +90,7 @@ class BodyStructure {
node.parsedHeader['content-description'] || null,
// body encoding
options.upperCaseKeys ? contentTransfer && contentTransfer.toUpperCase() || '7bit' : contentTransfer,
options.upperCaseKeys ? (contentTransfer && contentTransfer.toUpperCase()) || '7bit' : contentTransfer,
// body size
node.size
@ -111,9 +107,8 @@ class BodyStructure {
getExtensionFields(node, options) {
options = options || {};
let languageString = node.parsedHeader['content-language'] &&
node.parsedHeader['content-language'].replace(/[ ,]+/g, ',').replace(/^,+|,+$/g, '');
let language = languageString && languageString.split(',') || null;
let languageString = node.parsedHeader['content-language'] && node.parsedHeader['content-language'].replace(/[ ,]+/g, ',').replace(/^,+|,+$/g, '');
let language = (languageString && languageString.split(',')) || null;
let data;
// if `contentLanguageString` is true, then use a string instead of single element array
@ -126,25 +121,24 @@ class BodyStructure {
node.parsedHeader['content-md5'] || null,
// body disposition
node.parsedHeader['content-disposition'] && [
options.upperCaseKeys ?
node.parsedHeader['content-disposition'].value.toUpperCase() :
node.parsedHeader['content-disposition'].value,
node.parsedHeader['content-disposition'].params &&
node.parsedHeader['content-disposition'].hasParams &&
this.flatten(Object.keys(node.parsedHeader['content-disposition'].params).map(key => {
let value = node.parsedHeader['content-disposition'].params[key];
try {
value = Buffer.from(libmime.decodeWords(value).trim());
} catch (E) {
// failed to parse value
}
return [
options.upperCaseKeys ? key.toUpperCase() : key,
value
];
})) || null
] || null,
(node.parsedHeader['content-disposition'] && [
options.upperCaseKeys ? node.parsedHeader['content-disposition'].value.toUpperCase() : node.parsedHeader['content-disposition'].value,
(node.parsedHeader['content-disposition'].params &&
node.parsedHeader['content-disposition'].hasParams &&
this.flatten(
Object.keys(node.parsedHeader['content-disposition'].params).map(key => {
let value = node.parsedHeader['content-disposition'].params[key];
try {
value = Buffer.from(libmime.decodeWords(value).trim());
} catch (E) {
// failed to parse value
}
return [options.upperCaseKeys ? key.toUpperCase() : key, value];
})
)) ||
null
]) ||
null,
// body language
language
@ -174,36 +168,35 @@ class BodyStructure {
processMultipartNode(node, options) {
options = options || {};
let data = (node.childNodes && node.childNodes.map(tree => this.createBodystructure(tree, options)) || [
[]
]).
concat([
let data = ((node.childNodes && node.childNodes.map(tree => this.createBodystructure(tree, options))) || [[]]).concat([
// body subtype
options.upperCaseKeys ? node.multipart && node.multipart.toUpperCase() || null : node.multipart,
options.upperCaseKeys ? (node.multipart && node.multipart.toUpperCase()) || null : node.multipart,
// body parameter parenthesized list
node.parsedHeader['content-type'] &&
node.parsedHeader['content-type'].hasParams &&
this.flatten(Object.keys(node.parsedHeader['content-type'].params).map(key => {
let value = node.parsedHeader['content-type'].params[key];
try {
value = Buffer.from(libmime.decodeWords(value).trim());
} catch (E) {
// failed to parse value
}
return [
options.upperCaseKeys ? key.toUpperCase() : key,
value
];
})) || null
(node.parsedHeader['content-type'] &&
node.parsedHeader['content-type'].hasParams &&
this.flatten(
Object.keys(node.parsedHeader['content-type'].params).map(key => {
let value = node.parsedHeader['content-type'].params[key];
try {
value = Buffer.from(libmime.decodeWords(value).trim());
} catch (E) {
// failed to parse value
}
return [options.upperCaseKeys ? key.toUpperCase() : key, value];
})
)) ||
null
]);
if (options.body) {
return data;
} else {
return data.
// skip body MD5 from extension fields
concat(this.getExtensionFields(node, options).slice(1));
return (
data
// skip body MD5 from extension fields
.concat(this.getExtensionFields(node, options).slice(1))
);
}
}
@ -217,9 +210,7 @@ class BodyStructure {
processTextNode(node, options) {
options = options || {};
let data = [].concat(this.getBasicFields(node, options)).concat([
node.lineCount
]);
let data = [].concat(this.getBasicFields(node, options)).concat([node.lineCount]);
if (!options.body) {
data = data.concat(this.getExtensionFields(node, options));
@ -261,10 +252,7 @@ class BodyStructure {
data.push(createEnvelope(node.message.parsedHeader));
data.push(this.createBodystructure(node.message, options));
data = data.concat(
node.lineCount
).
concat(this.getExtensionFields(node, options));
data = data.concat(node.lineCount).concat(this.getExtensionFields(node, options));
return data;
}
@ -291,7 +279,6 @@ class BodyStructure {
}
return result;
}
}
// Expose to the world

View file

@ -11,8 +11,7 @@ const punycode = require('punycode');
* @param {Object} message A parsed mime tree node
* @return {Object} ENVELOPE compatible object
*/
module.exports = function (header) {
module.exports = function(header) {
let subject = Array.isArray(header.subject) ? header.subject.reverse().filter(line => line.trim()) : header.subject;
subject = Buffer.from(subject || '', 'binary').toString();
@ -78,9 +77,7 @@ function processAddress(arr, defaults) {
domain = Buffer.from(punycode.toUnicode(domain));
}
result.push([
name, null, user, domain
]);
result.push([name, null, user, domain]);
} else {
// Handle group syntax
let name = addr.name || '';

View file

@ -15,6 +15,7 @@ const iconv = require('iconv-lite');
const he = require('he');
const htmlToText = require('html-to-text');
const crypto = require('crypto');
let cryptoAsync;
try {
cryptoAsync = require('@ronomon/crypto-async'); // eslint-disable-line global-require
@ -23,7 +24,6 @@ try {
}
class Indexer {
constructor(options) {
this.options = options || {};
this.fetchOptions = this.options.fetchOptions || {};
@ -67,7 +67,6 @@ class Indexer {
};
let walk = (node, next) => {
if (!textOnly || !root) {
append(formatHeaders(node.header).join('\r\n') + '\r\n');
}
@ -157,7 +156,6 @@ class Indexer {
};
let walk = (node, next) => {
if (aborted) {
return next();
}
@ -234,9 +232,11 @@ class Indexer {
}
};
setImmediate(walk.bind(null, mimeTree, () => {
res.end();
}));
setImmediate(
walk.bind(null, mimeTree, () => {
res.end();
})
);
// if called then stops resolving rest of the message
res.abort = () => {
@ -290,7 +290,9 @@ class Indexer {
let parsedDisposition = node.parsedHeader['content-disposition'];
let transferEncoding = (node.parsedHeader['content-transfer-encoding'] || '7bit').toLowerCase().trim();
let contentType = (parsedContentType && parsedContentType.value || (node.rootNode ? 'text/plain' : 'application/octet-stream')).toLowerCase().trim();
let contentType = ((parsedContentType && parsedContentType.value) || (node.rootNode ? 'text/plain' : 'application/octet-stream'))
.toLowerCase()
.trim();
alternative = alternative || contentType === 'multipart/alternative';
related = related || contentType === 'multipart/related';
@ -302,13 +304,16 @@ class Indexer {
}
}
let disposition = (parsedDisposition && parsedDisposition.value || '').toLowerCase().trim() || false;
let disposition = ((parsedDisposition && parsedDisposition.value) || '').toLowerCase().trim() || false;
let isInlineText = false;
let isMultipart = contentType.split('/')[0] === 'multipart';
// If the current node is HTML or Plaintext then allow larger content included in the mime tree
// Also decode text/html value
if (['text/plain', 'text/html', 'text/rfc822-headers', 'message/delivery-status'].includes(contentType) && (!disposition || disposition === 'inline')) {
if (
['text/plain', 'text/html', 'text/rfc822-headers', 'message/delivery-status'].includes(contentType) &&
(!disposition || disposition === 'inline')
) {
isInlineText = true;
if (node.body && node.body.length) {
let charset = parsedContentType.params.charset || 'windows-1257';
@ -353,7 +358,12 @@ class Indexer {
let attachmentId = 'ATT' + leftPad(++idcount, '0', 5);
map[attachmentId] = new ObjectID();
let fileName = (node.parsedHeader['content-disposition'] && node.parsedHeader['content-disposition'].params && node.parsedHeader['content-disposition'].params.filename) || (node.parsedHeader['content-type'] && node.parsedHeader['content-type'].params && node.parsedHeader['content-type'].params.name) || false;
let fileName =
(node.parsedHeader['content-disposition'] &&
node.parsedHeader['content-disposition'].params &&
node.parsedHeader['content-disposition'].params.filename) ||
(node.parsedHeader['content-type'] && node.parsedHeader['content-type'].params && node.parsedHeader['content-type'].params.name) ||
false;
let contentId = (node.parsedHeader['content-id'] || '').toString().replace(/<|>/g, '').trim();
if (fileName) {
@ -363,7 +373,7 @@ class Indexer {
// failed to parse filename, keep as is (most probably an unknown charset is used)
}
} else {
fileName = (crypto.randomBytes(4).toString('hex') + '.' + libmime.detectExtension(contentType));
fileName = crypto.randomBytes(4).toString('hex') + '.' + libmime.detectExtension(contentType);
}
cidMap.set(contentId, {
@ -425,13 +435,14 @@ class Indexer {
walk(mimeTree, false, false);
let updateCidLinks = str => str.replace(/\bcid:([^\s"']+)/g, (match, cid) => {
if (cidMap.has(cid)) {
let attachment = cidMap.get(cid);
return 'attachment:' + messageId + '/' + attachment.id.toString();
}
return match;
});
let updateCidLinks = str =>
str.replace(/\bcid:([^\s"']+)/g, (match, cid) => {
if (cidMap.has(cid)) {
let attachment = cidMap.get(cid);
return 'attachment:' + messageId + '/' + attachment.id.toString();
}
return match;
});
maildata.html = htmlContent.filter(str => str.trim()).map(updateCidLinks);
maildata.text = textContent.filter(str => str.trim()).map(updateCidLinks).join('\n').trim();
@ -447,10 +458,8 @@ class Indexer {
let nodes = maildata.nodes;
let storeNode = () => {
if (pos >= nodes.length) {
// replace attachment IDs with ObjectIDs in the mimeTree
let walk = (node, next) => {
if (node.attachmentId && maildata.map[node.attachmentId]) {
node.attachmentId = maildata.map[node.attachmentId];
}
@ -546,7 +555,6 @@ class Indexer {
* @return {Array} BODY object as a structured Array
*/
getBody(mimeTree) {
// BODY BODYSTRUCTURE without extension data
let body = new BodyStructure(mimeTree, {
upperCaseKeys: true,
@ -563,7 +571,6 @@ class Indexer {
* @return {Array} BODYSTRUCTURE object as a structured Array
*/
getBodyStructure(mimeTree) {
// full BODYSTRUCTURE
let bodystructure = new BodyStructure(mimeTree, {
upperCaseKeys: true,
@ -647,7 +654,6 @@ class Indexer {
sent = true;
return callback(null, Buffer.concat(buffers, buflen));
});
} else {
return setImmediate(() => callback(null, Buffer.from((data || '').toString(), 'binary')));
}
@ -711,20 +717,28 @@ class Indexer {
if (!selector.headers || !selector.headers.length) {
return '\r\n\r\n';
}
return formatHeaders(node.header).filter(line => {
let key = line.split(':').shift().toLowerCase().trim();
return selector.headers.indexOf(key) >= 0;
}).join('\r\n') + '\r\n\r\n';
return (
formatHeaders(node.header)
.filter(line => {
let key = line.split(':').shift().toLowerCase().trim();
return selector.headers.indexOf(key) >= 0;
})
.join('\r\n') + '\r\n\r\n'
);
case 'header.fields.not':
// BODY[HEADER.FIELDS.NOT (Key1 Key2 KeyN)] all but selected header keys
if (!selector.headers || !selector.headers.length) {
return formatHeaders(node.header).join('\r\n') + '\r\n\r\n';
}
return formatHeaders(node.header).filter(line => {
let key = line.split(':').shift().toLowerCase().trim();
return selector.headers.indexOf(key) < 0;
}).join('\r\n') + '\r\n\r\n';
return (
formatHeaders(node.header)
.filter(line => {
let key = line.split(':').shift().toLowerCase().trim();
return selector.headers.indexOf(key) < 0;
})
.join('\r\n') + '\r\n\r\n'
);
case 'mime':
// BODY[1.2.3.MIME] mime node header
@ -754,19 +768,21 @@ function formatHeaders(headers) {
return headers;
}
function textToHtml(str) {
let text = '<p>' + he.
// encode special chars
encode(
str, {
useNamedReferences: true
}).
replace(/\r?\n/g, '\n').trim(). // normalize line endings
replace(/[ \t]+$/mg, '').trim(). // trim empty line endings
replace(/\n\n+/g, '</p><p>').trim(). // insert <p> to multiple linebreaks
replace(/\n/g, '<br/>') + // insert <br> to single linebreaks
let text =
'<p>' +
he
// encode special chars
.encode(str, {
useNamedReferences: true
})
.replace(/\r?\n/g, '\n')
.trim() // normalize line endings
.replace(/[ \t]+$/gm, '')
.trim() // trim empty line endings
.replace(/\n\n+/g, '</p><p>')
.trim() // insert <p> to multiple linebreaks
.replace(/\n/g, '<br/>') + // insert <br> to single linebreaks
'</p>';
return text;

View file

@ -1,6 +1,6 @@
'use strict';
let addressparser = require('nodemailer/lib/addressparser');
const addressparser = require('nodemailer/lib/addressparser');
/**
* Parses a RFC822 message into a structured object (JSON compatible)
@ -9,9 +9,7 @@ let addressparser = require('nodemailer/lib/addressparser');
* @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');
@ -38,7 +36,6 @@ class MIMEParser {
line = this.readLine();
switch (this._node.state) {
case 'header': // process header section
if (this.rawBody) {
this.rawBody += prevBr + line;
@ -58,8 +55,11 @@ class MIMEParser {
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') {
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(''));
}
@ -78,7 +78,8 @@ class MIMEParser {
}
break;
default: // never should be reached
default:
// never should be reached
throw new Error('Unexpected state');
}
@ -107,7 +108,6 @@ class MIMEParser {
* from the tree (circular references prohibit conversion to JSON)
*/
finalizeTree() {
if (this._node.state === 'header') {
this.processNodeHeader();
this.processContentType();
@ -125,9 +125,12 @@ class MIMEParser {
node.lineCount = node.body.length;
node.body = Buffer.from(
node.body.join('').
// ensure proper line endings
replace(/\r?\n/g, '\r\n'), 'binary');
node.body
.join('')
// ensure proper line endings
.replace(/\r?\n/g, '\r\n'),
'binary'
);
node.size = node.body.length;
}
node.childNodes.forEach(walker);
@ -243,7 +246,8 @@ class MIMEParser {
subtype: '',
params: {}
},
match, processEncodedWords = {};
match,
processEncodedWords = {};
(headerValue || '').split(';').forEach((part, i) => {
let key, value;

1283
imap.js

File diff suppressed because it is too large Load diff

View file

@ -154,7 +154,6 @@
}
}, {
"name": "retention_time",
"expireAfterSeconds": 0,
"key": {
"exp": 1,
"rdate": 1
@ -184,7 +183,7 @@
}
}, {
"name": "autoexpire",
"expireAfterSeconds": "21600",
"expireAfterSeconds": 21600,
"key": {
"created": 1
}

View file

@ -31,7 +31,6 @@ const IGNORE_HEADERS = [
];
class MessageHandler {
constructor(database, redisConfig) {
this.database = database;
this.redis = redisConfig || tools.redisConfig(config.redis);
@ -79,7 +78,6 @@ class MessageHandler {
// Monster method for inserting new messages to a mailbox
// TODO: Refactor into smaller pieces
add(options, callback) {
let prepared = options.prepared || this.prepareMessage(options);
let id = prepared.id;
@ -100,208 +98,212 @@ class MessageHandler {
return callback(err);
}
this.checkExistingMessage(mailbox._id, {
hdate,
msgid,
flags
}, options, (...args) => {
if (args[0] || args[1]) {
return callback(...args);
}
let cleanup = (...args) => {
if (!args[0]) {
this.checkExistingMessage(
mailbox._id,
{
hdate,
msgid,
flags
},
options,
(...args) => {
if (args[0] || args[1]) {
return callback(...args);
}
let attachments = Object.keys(maildata.map || {}).map(key => maildata.map[key]);
if (!attachments.length) {
return callback(...args);
}
// error occured, remove attachments
this.database.collection('attachments.files').deleteMany({
_id: {
$in: attachments
}
}, err => {
if (err) {
// ignore as we don't really care if we have orphans or not
let cleanup = (...args) => {
if (!args[0]) {
return callback(...args);
}
return callback(null, true);
});
};
let attachments = Object.keys(maildata.map || {}).map(key => maildata.map[key]);
if (!attachments.length) {
return callback(...args);
}
this.indexer.storeNodeBodies(id, maildata, mimeTree, err => {
if (err) {
return cleanup(err);
}
// prepare message object
let message = {
_id: id,
v: SCHEMA_VERSION,
// if true then expirest after rdate + retention
exp: !!mailbox.retention,
rdate: Date.now() + (mailbox.retention || 0),
idate,
hdate,
flags,
size,
// some custom metadata about the delivery
meta: options.meta || {},
// list filter IDs that matched this message
filters: Array.isArray(options.filters) ? options.filters : [].concat(options.filters || []),
headers,
mimeTree,
envelope,
bodystructure,
msgid,
// use boolean for more commonly used (and searched for) flags
seen: flags.includes('\\Seen'),
flagged: flags.includes('\\Flagged'),
deleted: flags.includes('\\Deleted'),
draft: flags.includes('\\Draft'),
magic: maildata.magic,
map: maildata.map
// error occured, remove attachments
this.database.collection('attachments.files').deleteMany({
_id: {
$in: attachments
}
}, () => callback(...args));
};
if (maildata.attachments && maildata.attachments.length) {
message.attachments = maildata.attachments;
message.ha = true;
} else {
message.ha = false;
}
let maxTextLength = 300 * 1024;
if (maildata.text) {
message.text = maildata.text.replace(/\r\n/g, '\n').trim();
message.text = message.text.length <= maxTextLength ? message.text : message.text.substr(0, maxTextLength);
message.intro = message.text.replace(/\s+/g, ' ').trim();
if (message.intro.length > 128) {
let intro = message.intro.substr(0, 128);
let lastSp = intro.lastIndexOf(' ');
if (lastSp > 0) {
intro = intro.substr(0, lastSp);
}
message.intro = intro + '…';
}
}
if (maildata.html && maildata.html.length) {
let htmlSize = 0;
message.html = maildata.html.map(html => {
if (htmlSize >= maxTextLength || !html) {
return '';
}
if (htmlSize + Buffer.byteLength(html) <= maxTextLength) {
htmlSize += Buffer.byteLength(html);
return html;
}
html = html.substr(0, htmlSize + Buffer.byteLength(html) - maxTextLength);
htmlSize += Buffer.byteLength(html);
return html;
}).filter(html => html);
}
this.database.collection('users').findOneAndUpdate({
_id: mailbox.user
}, {
$inc: {
storageUsed: size
}
}, err => {
this.indexer.storeNodeBodies(id, maildata, mimeTree, err => {
if (err) {
return cleanup(err);
}
let rollback = err => {
this.database.collection('users').findOneAndUpdate({
_id: mailbox.user
}, {
$inc: {
storageUsed: -size
}
}, () => {
cleanup(err);
});
// prepare message object
let message = {
_id: id,
v: SCHEMA_VERSION,
// if true then expirest after rdate + retention
exp: !!mailbox.retention,
rdate: Date.now() + (mailbox.retention || 0),
idate,
hdate,
flags,
size,
// some custom metadata about the delivery
meta: options.meta || {},
// list filter IDs that matched this message
filters: Array.isArray(options.filters) ? options.filters : [].concat(options.filters || []),
headers,
mimeTree,
envelope,
bodystructure,
msgid,
// use boolean for more commonly used (and searched for) flags
seen: flags.includes('\\Seen'),
flagged: flags.includes('\\Flagged'),
deleted: flags.includes('\\Deleted'),
draft: flags.includes('\\Draft'),
magic: maildata.magic,
map: maildata.map
};
// acquire new UID+MODSEQ
this.database.collection('mailboxes').findOneAndUpdate({
_id: mailbox._id
if (maildata.attachments && maildata.attachments.length) {
message.attachments = maildata.attachments;
message.ha = true;
} else {
message.ha = false;
}
let maxTextLength = 300 * 1024;
if (maildata.text) {
message.text = maildata.text.replace(/\r\n/g, '\n').trim();
message.text = message.text.length <= maxTextLength ? message.text : message.text.substr(0, maxTextLength);
message.intro = message.text.replace(/\s+/g, ' ').trim();
if (message.intro.length > 128) {
let intro = message.intro.substr(0, 128);
let lastSp = intro.lastIndexOf(' ');
if (lastSp > 0) {
intro = intro.substr(0, lastSp);
}
message.intro = intro + '…';
}
}
if (maildata.html && maildata.html.length) {
let htmlSize = 0;
message.html = maildata.html
.map(html => {
if (htmlSize >= maxTextLength || !html) {
return '';
}
if (htmlSize + Buffer.byteLength(html) <= maxTextLength) {
htmlSize += Buffer.byteLength(html);
return html;
}
html = html.substr(0, htmlSize + Buffer.byteLength(html) - maxTextLength);
htmlSize += Buffer.byteLength(html);
return html;
})
.filter(html => html);
}
this.database.collection('users').findOneAndUpdate({
_id: mailbox.user
}, {
$inc: {
// allocate bot UID and MODSEQ values so when journal is later sorted by
// modseq then UIDs are always in ascending order
uidNext: 1,
modifyIndex: 1
storageUsed: size
}
}, (err, item) => {
}, err => {
if (err) {
return rollback(err);
return cleanup(err);
}
if (!item || !item.value) {
// was not able to acquire a lock
let err = new Error('Mailbox is missing');
err.imapResponse = 'TRYCREATE';
return rollback(err);
}
let rollback = err => {
this.database.collection('users').findOneAndUpdate({
_id: mailbox.user
}, {
$inc: {
storageUsed: -size
}
}, () => {
cleanup(err);
});
};
let mailbox = item.value;
// updated message object by setting mailbox specific values
message.mailbox = mailbox._id;
message.user = mailbox.user;
message.uid = mailbox.uidNext;
message.modseq = mailbox.modifyIndex + 1;
this.database.collection('messages').insertOne(message, err => {
// acquire new UID+MODSEQ
this.database.collection('mailboxes').findOneAndUpdate({
_id: mailbox._id
}, {
$inc: {
// allocate bot UID and MODSEQ values so when journal is later sorted by
// modseq then UIDs are always in ascending order
uidNext: 1,
modifyIndex: 1
}
}, (err, item) => {
if (err) {
return rollback(err);
}
let uidValidity = mailbox.uidValidity;
let uid = message.uid;
if (options.session && options.session.selected && options.session.selected.mailbox === mailbox.path) {
options.session.writeStream.write(options.session.formatResponse('EXISTS', message.uid));
if (!item || !item.value) {
// was not able to acquire a lock
let err = new Error('Mailbox is missing');
err.imapResponse = 'TRYCREATE';
return rollback(err);
}
this.notifier.addEntries(mailbox, false, {
command: 'EXISTS',
uid: message.uid,
ignore: options.session && options.session.id,
message: message._id,
modseq: message.modseq
}, () => {
this.notifier.fire(mailbox.user, mailbox.path);
return cleanup(null, true, {
uidValidity,
uid,
id: message._id
});
let mailbox = item.value;
// updated message object by setting mailbox specific values
message.mailbox = mailbox._id;
message.user = mailbox.user;
message.uid = mailbox.uidNext;
message.modseq = mailbox.modifyIndex + 1;
this.database.collection('messages').insertOne(message, err => {
if (err) {
return rollback(err);
}
let uidValidity = mailbox.uidValidity;
let uid = message.uid;
if (options.session && options.session.selected && options.session.selected.mailbox === mailbox.path) {
options.session.writeStream.write(options.session.formatResponse('EXISTS', message.uid));
}
this.notifier.addEntries(
mailbox,
false,
{
command: 'EXISTS',
uid: message.uid,
ignore: options.session && options.session.id,
message: message._id,
modseq: message.modseq
},
() => {
this.notifier.fire(mailbox.user, mailbox.path);
return cleanup(null, true, {
uidValidity,
uid,
id: message._id
});
}
);
});
});
});
});
});
});
}
);
});
}
@ -387,28 +389,37 @@ class MessageHandler {
if (options.session && options.session.selected && options.session.selected.mailbox === mailbox.path) {
options.session.writeStream.write(options.session.formatResponse('EXISTS', updated.uid));
}
this.notifier.addEntries(mailbox, false, {
command: 'EXPUNGE',
ignore: options.session && options.session.id,
uid: existing.uid,
message: existing._id
}, () => {
this.notifier.addEntries(mailbox, false, {
command: 'EXISTS',
uid: updated.uid,
this.notifier.addEntries(
mailbox,
false,
{
command: 'EXPUNGE',
ignore: options.session && options.session.id,
message: updated._id,
modseq: updated.modseq
}, () => {
this.notifier.fire(mailbox.user, mailbox.path);
return callback(null, true, {
uidValidity: mailbox.uidValidity,
uid,
id: existing._id
});
});
});
uid: existing.uid,
message: existing._id
},
() => {
this.notifier.addEntries(
mailbox,
false,
{
command: 'EXISTS',
uid: updated.uid,
ignore: options.session && options.session.id,
message: updated._id,
modseq: updated.modseq
},
() => {
this.notifier.fire(mailbox.user, mailbox.path);
return callback(null, true, {
uidValidity: mailbox.uidValidity,
uid,
id: existing._id
});
}
);
}
);
});
});
});
@ -417,30 +428,37 @@ class MessageHandler {
updateQuota(mailbox, inc, callback) {
inc = inc || {};
this.database.collection('users').findOneAndUpdate({
_id: mailbox.user
}, {
$inc: {
storageUsed: Number(inc.storageUsed) || 0
}
}, callback);
this.database.collection('users').findOneAndUpdate(
{
_id: mailbox.user
},
{
$inc: {
storageUsed: Number(inc.storageUsed) || 0
}
},
callback
);
}
del(options, callback) {
let getMessage = next => {
if (options.message) {
return next(null, options.message);
}
this.database.collection('messages').findOne(options.query, {
fields: {
mailbox: true,
uid: true,
size: true,
map: true,
magic: true
}
}, next);
this.database.collection('messages').findOne(
options.query,
{
fields: {
mailbox: true,
uid: true,
size: true,
map: true,
magic: true
}
},
next
);
};
getMessage((err, message) => {
@ -452,75 +470,85 @@ class MessageHandler {
return callback(new Error('Message does not exist'));
}
this.getMailbox({
mailbox: options.mailbox || message.mailbox
}, (err, mailbox) => {
if (err) {
return callback(err);
}
this.database.collection('messages').deleteOne({
_id: message._id
}, err => {
this.getMailbox(
{
mailbox: options.mailbox || message.mailbox
},
(err, mailbox) => {
if (err) {
return callback(err);
}
this.updateQuota(mailbox, {
storageUsed: -message.size
}, () => {
this.database.collection('messages').deleteOne({
_id: message._id
}, err => {
if (err) {
return callback(err);
}
let updateAttachments = next => {
let attachments = Object.keys(message.map || {}).map(key => message.map[key]);
if (!attachments.length) {
return next();
this.updateQuota(
mailbox,
{
storageUsed: -message.size
},
() => {
let updateAttachments = next => {
let attachments = Object.keys(message.map || {}).map(key => message.map[key]);
if (!attachments.length) {
return next();
}
// remove link to message from attachments (if any exist)
this.database.collection('attachments.files').updateMany({
_id: {
$in: attachments
}
}, {
$inc: {
'metadata.c': -1,
'metadata.m': -message.magic
}
}, {
multi: true,
w: 1
}, err => {
if (err) {
// ignore as we don't really care if we have orphans or not
}
next();
});
};
updateAttachments(() => {
if (options.session && options.session.selected && options.session.selected.mailbox === mailbox.path) {
options.session.writeStream.write(options.session.formatResponse('EXPUNGE', message.uid));
}
this.notifier.addEntries(
mailbox,
false,
{
command: 'EXPUNGE',
ignore: options.session && options.session.id,
uid: message.uid,
message: message._id
},
() => {
this.notifier.fire(mailbox.user, mailbox.path);
if (options.skipAttachments) {
return callback(null, true);
}
return callback(null, true);
}
);
});
}
// remove link to message from attachments (if any exist)
this.database.collection('attachments.files').updateMany({
_id: {
$in: attachments
}
}, {
$inc: {
'metadata.c': -1,
'metadata.m': -message.magic
}
}, {
multi: true,
w: 1
}, err => {
if (err) {
// ignore as we don't really care if we have orphans or not
}
next();
});
};
updateAttachments(() => {
if (options.session && options.session.selected && options.session.selected.mailbox === mailbox.path) {
options.session.writeStream.write(options.session.formatResponse('EXPUNGE', message.uid));
}
this.notifier.addEntries(mailbox, false, {
command: 'EXPUNGE',
ignore: options.session && options.session.id,
uid: message.uid,
message: message._id
}, () => {
this.notifier.fire(mailbox.user, mailbox.path);
if (options.skipAttachments) {
return callback(null, true);
}
return callback(null, true);
});
});
);
});
});
});
}
);
});
}
@ -546,17 +574,18 @@ class MessageHandler {
}, {
uidNext: true
}, () => {
let cursor = this.database.collection('messages').find({
mailbox: mailbox._id,
uid: {
$in: options.messages || []
}
}).project({
uid: 1
}).sort([
['uid', 1]
]);
let cursor = this.database
.collection('messages')
.find({
mailbox: mailbox._id,
uid: {
$in: options.messages || []
}
})
.project({
uid: 1
})
.sort([['uid', 1]]);
let sourceUid = [];
let destinationUid = [];
@ -565,7 +594,6 @@ class MessageHandler {
let existsEntries = [];
let done = err => {
let next = () => {
if (err) {
return callback(err);
@ -692,43 +720,45 @@ class MessageHandler {
}
generateIndexedHeaders(headersArray) {
return (headersArray || []).map(line => {
line = Buffer.from(line, 'binary').toString();
return (headersArray || [])
.map(line => {
line = Buffer.from(line, 'binary').toString();
let key = line.substr(0, line.indexOf(':')).trim().toLowerCase();
let key = line.substr(0, line.indexOf(':')).trim().toLowerCase();
if (IGNORE_HEADERS.find(prefix => key.indexOf(prefix) === 0)) {
// do not index this header
return false;
}
if (IGNORE_HEADERS.find(prefix => key.indexOf(prefix) === 0)) {
// do not index this header
return false;
}
let value = line.substr(line.indexOf(':') + 1).trim().toLowerCase().replace(/\s*\r?\n\s*/g, ' ');
let value = line.substr(line.indexOf(':') + 1).trim().toLowerCase().replace(/\s*\r?\n\s*/g, ' ');
try {
value = libmime.decodeWords(value);
} catch (E) {
// ignore
}
try {
value = libmime.decodeWords(value);
} catch (E) {
// ignore
}
// trim long values as mongodb indexed fields can not be too long
// trim long values as mongodb indexed fields can not be too long
if (Buffer.byteLength(key, 'utf-8') >= 255) {
key = Buffer.from(key).slice(0, 255).toString();
key = key.substr(0, key.length - 4);
}
if (Buffer.byteLength(key, 'utf-8') >= 255) {
key = Buffer.from(key).slice(0, 255).toString();
key = key.substr(0, key.length - 4);
}
if (Buffer.byteLength(value, 'utf-8') >= 880) {
// value exceeds MongoDB max indexed value length
value = Buffer.from(value).slice(0, 880).toString();
// remove last 4 chars to be sure we do not have any incomplete unicode sequences
value = value.substr(0, value.length - 4);
}
if (Buffer.byteLength(value, 'utf-8') >= 880) {
// value exceeds MongoDB max indexed value length
value = Buffer.from(value).slice(0, 880).toString();
// remove last 4 chars to be sure we do not have any incomplete unicode sequences
value = value.substr(0, value.length - 4);
}
return {
key,
value
};
}).filter(line => line);
return {
key,
value
};
})
.filter(line => line);
}
prepareMessage(options) {
@ -740,8 +770,8 @@ class MessageHandler {
let bodystructure = this.indexer.getBodyStructure(mimeTree);
let envelope = this.indexer.getEnvelope(mimeTree);
let idate = options.date && new Date(options.date) || new Date();
let hdate = mimeTree.parsedHeader.date && new Date(mimeTree.parsedHeader.date) || false;
let idate = (options.date && new Date(options.date)) || new Date();
let hdate = (mimeTree.parsedHeader.date && new Date(mimeTree.parsedHeader.date)) || false;
let flags = [].concat(options.flags || []);
@ -749,7 +779,7 @@ class MessageHandler {
hdate = idate;
}
let msgid = envelope[9] || ('<' + uuidV1() + '@wildduck.email>');
let msgid = envelope[9] || '<' + uuidV1() + '@wildduck.email>';
let headers = this.generateIndexedHeaders(mimeTree.header);