wildduck/imap-core/lib/imap-tools.js

693 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const Indexer = require('./indexer/indexer');
const utf7 = require('utf7').imap;
const libmime = require('libmime');
const punycode = require('punycode');
module.exports.systemFlagsFormatted = ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen'];
module.exports.systemFlags = ['\\answered', '\\flagged', '\\draft', '\\deleted', '\\seen'];
module.exports.fetchSchema = {
body: [
true,
{
type: /^(\d+\.)*(CONTENT|HEADER|HEADER\.FIELDS|HEADER\.FIELDS\.NOT|TEXT|MIME|\d+)$/i,
headers: /^(\d+\.)*(HEADER\.FIELDS|HEADER\.FIELDS\.NOT)$/i,
startFrom: 'optional',
maxLength: 'optional'
}
],
bodystructure: true,
envelope: true,
flags: true,
internaldate: true,
rfc822: true,
'rfc822.header': true,
'rfc822.size': true,
'rfc822.text': true,
modseq: true,
uid: true
};
module.exports.searchSchema = {
charset: ['string'],
all: true,
answered: true,
bcc: ['string'],
before: ['date'],
body: ['string'],
cc: ['string'],
deleted: true,
draft: true,
flagged: true,
from: ['string'],
header: ['string', 'string'],
keyword: ['string'],
larger: ['number'],
modseq: [['string', 'string', 'number'], ['number']],
new: true,
not: ['expression'],
old: true,
on: ['date'],
or: ['expression', 'expression'],
recent: true,
seen: true,
sentbefore: ['date'],
senton: ['date'],
sentsince: ['date'],
since: ['date'],
smaller: ['number'],
subject: ['string'],
text: ['string'],
to: ['string'],
uid: ['sequence'],
unanswered: true,
undeleted: true,
undraft: true,
unflagged: true,
unkeyword: ['string'],
unseen: true
};
module.exports.searchMapping = {
all: {
key: 'all',
value: [true]
},
answered: {
key: 'flag',
value: ['\\Answered', true]
},
bcc: {
key: 'header',
value: ['bcc', '$1']
},
before: {
key: 'internaldate',
value: ['<', '$1']
},
cc: {
key: 'header',
value: ['cc', '$1']
},
deleted: {
key: 'flag',
value: ['\\Deleted', true]
},
draft: {
key: 'flag',
value: ['\\Draft', true]
},
flagged: {
key: 'flag',
value: ['\\Flagged', true]
},
from: {
key: 'header',
value: ['from', '$1']
},
keyword: {
key: 'flag',
value: ['$1', true]
},
larger: {
key: 'size',
value: ['>', '$1']
},
new: {
key: 'flag',
value: ['\\Recent', true, '\\Seen', false]
},
old: {
key: 'flag',
value: ['\\Recent', false]
},
on: {
key: 'internaldate',
value: ['=', '$1']
},
recent: {
key: 'flag',
value: ['\\Recent', true]
},
seen: {
key: 'flag',
value: ['\\Seen', true]
},
sentbefore: {
key: 'date',
value: ['<', '$1']
},
senton: {
key: 'date',
value: ['=', '$1']
},
sentsince: {
key: 'date',
value: ['>=', '$1']
},
since: {
key: 'internaldate',
value: ['>=', '$1']
},
smaller: {
key: 'size',
value: ['<', '$1']
},
subject: {
key: 'header',
value: ['subject', '$1']
},
to: {
key: 'header',
value: ['to', '$1']
},
unanswered: {
key: 'flag',
value: ['\\Answered', false]
},
undeleted: {
key: 'flag',
value: ['\\Deleted', false]
},
undraft: {
key: 'flag',
value: ['\\Draft', false]
},
unflagged: {
key: 'flag',
value: ['\\Flagged', false]
},
unkeyword: {
key: 'flag',
value: ['$1', false]
},
unseen: {
key: 'flag',
value: ['\\Seen', false]
}
};
/**
* Checks if a sequence range string is valid or not
*
* @param {range} range Sequence range, eg "1,2,3:7"
* @returns {Boolean} True if the string looks like a sequence range
*/
module.exports.validateSequnce = function(range) {
return !!(range.length && /^(\d+|\*)(:\d+|:\*)?(,(\d+|\*)(:\d+|:\*)?)*$/.test(range));
};
module.exports.normalizeMailbox = function(mailbox, utf7Encoded) {
if (!mailbox) {
return '';
}
// trim slashes
mailbox = mailbox.replace(/^\/|\/$/g, () => '');
// Normalize case insensitive INBOX to always use uppercase
let parts = mailbox.split('/');
if (parts[0].toUpperCase() === 'INBOX') {
parts[0] = 'INBOX';
}
if (utf7Encoded) {
parts = parts.map(value => utf7.decode(value));
}
mailbox = parts.join('/');
return mailbox;
};
module.exports.generateFolderListing = function(folders, skipHierarchy) {
let items = new Map();
let parents = [];
folders.forEach(folder => {
let item;
if (typeof folder === 'string') {
folder = {
path: folder
};
}
if (!folder || typeof folder !== 'object') {
return;
}
let path = module.exports.normalizeMailbox(folder.path);
let parent, parentPath;
if (!path) {
return;
}
parent = path.split('/');
parent.pop();
while (parent.length) {
parentPath = parent.join('/');
if (parent && parents.indexOf(parentPath) < 0) {
parents.push(parentPath);
}
parent.pop();
}
item = {
// flags array is used to store permanentflags
//flags: [].concat(folder.flags || []),
flags: [],
path
};
if (typeof folder.specialUse === 'string' && folder.specialUse) {
item.specialUse = folder.specialUse;
}
items.set(path, item);
});
// ensure INBOX
if (!items.has('INBOX')) {
items.set('INBOX', {
path: 'INBOX',
flags: []
});
}
// Adds \HasChildren flag for parent folders
parents.forEach(path => {
if (!items.has(path) && !skipHierarchy) {
// add virtual hierarchy folders
items.set(path, {
flags: ['\\Noselect'],
path
});
}
let parent = items.get(path);
if (parent && parent.flags.indexOf('\\HasChildren') < 0) {
parent.flags.push('\\HasChildren');
}
});
// converts cache Map to a response array
let result = [];
items.forEach(folder => {
// Adds \HasNoChildren flag for leaf folders
if (folder.flags.indexOf('\\HasChildren') < 0 && folder.flags.indexOf('\\HasNoChildren') < 0) {
folder.flags.push('\\HasNoChildren');
}
result.push(folder);
});
// sorts folders
result.sort((a, b) => {
let aParts = a.path.split('/');
let bParts = b.path.split('/');
for (let i = 0; i < aParts.length; i++) {
if (!bParts[i]) {
return 1;
}
if (aParts[i] !== bParts[i]) {
// prefer INBOX when sorting
if (i === 0 && aParts[i] === 'INBOX') {
return -1;
} else if (i === 0 && bParts[i] === 'INBOX') {
return 1;
}
return aParts[i].localeCompare(bParts[i]);
}
}
return 0;
});
return result;
};
module.exports.filterFolders = function(folders, query) {
query = query
// remove excess * and %
.replace(/\*\*+/g, '*')
.replace(/%%+/g, '%')
// escape special characters
.replace(/([\\^$+?!.():=[\]|,-])/g, '\\$1')
// setup *
.replace(/[*]/g, '.*')
// setup %
.replace(/[%]/g, '[^/]*');
let regex = new RegExp('^' + query + '$', '');
return folders.filter(folder => !!regex.test(folder.path));
};
module.exports.getMessageRange = function(uidList, range, isUid) {
range = (range || '').toString();
let result = [];
let rangeParts = range.split(',');
let uid, i, len;
let totalMessages = uidList.length;
let maxUid = 0;
let inRange = (nr, ranges, total) => {
let range, from, to;
for (let i = 0, len = ranges.length; i < len; i++) {
range = ranges[i];
to = range.split(':');
from = to.shift();
if (from === '*') {
from = total;
}
from = Number(from) || 1;
to = to.pop() || from;
to = Number((to === '*' && total) || to) || from;
if (nr >= Math.min(from, to) && nr <= Math.max(from, to)) {
return true;
}
}
return false;
};
for (i = 0, len = uidList.length; i < len; i++) {
if (uidList[i] > maxUid) {
maxUid = uidList[i];
}
}
for (i = 0, len = uidList.length; i < len; i++) {
uid = uidList[i] || 1;
if (inRange(isUid ? uid : i + 1, rangeParts, isUid ? maxUid : totalMessages)) {
result.push(uidList[i]);
}
}
return result;
};
module.exports.packMessageRange = function(uidList) {
if (!Array.isArray(uidList)) {
uidList = [].concat(uidList || []);
}
if (!uidList.length) {
return '';
}
uidList.sort((a, b) => a - b);
let last = uidList[uidList.length - 1];
let result = [[last]];
for (let i = uidList.length - 2; i >= 0; i--) {
if (uidList[i] === uidList[i + 1] - 1) {
result[0].unshift(uidList[i]);
continue;
}
result.unshift([uidList[i]]);
}
result = result.map(item => {
if (item.length === 1) {
return item[0];
}
return item.shift() + ':' + item.pop();
});
return result.join(',');
};
/**
* Returns a date in GMT timezone
*
* @param {Date} date Date object to parse
* @returns {String} Internaldate formatted date
*/
module.exports.formatInternalDate = function(date) {
let day = date.getUTCDate(),
month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][date.getUTCMonth()],
year = date.getUTCFullYear(),
hour = date.getUTCHours(),
minute = date.getUTCMinutes(),
second = date.getUTCSeconds(),
tz = 0, //date.getTimezoneOffset(),
tzHours = Math.abs(Math.floor(tz / 60)),
tzMins = Math.abs(tz) - tzHours * 60;
return (
(day < 10 ? '0' : '') +
day +
'-' +
month +
'-' +
year +
' ' +
(hour < 10 ? '0' : '') +
hour +
':' +
(minute < 10 ? '0' : '') +
minute +
':' +
(second < 10 ? '0' : '') +
second +
' ' +
(tz > 0 ? '-' : '+') +
(tzHours < 10 ? '0' : '') +
tzHours +
(tzMins < 10 ? '0' : '') +
tzMins
);
};
/**
* Converts query data and message into an array of query responses.
*
* Message object must have the following properties:
*
* * raw string (binary) or buffer with the rfc822 contents of the message
* * uid message UID
* * flags - an array with message flags
* * date - internaldate date object
*
* Additionally the message object *should* have the following properties (if not present then generated automatically):
*
* * mimeTree - message MIME tree object
* * envelope - message IMAP envelope object
* * bodystructure - message bodustructure object
* * bodystructureShort - bodyscructure for the BODY query
*
* @param {Array} query Query objects
* @param {Object} message Message object
* @param {Object} options Options for the indexer
* @returns {Array} Resolved responses
*/
module.exports.getQueryResponse = function(query, message, options) {
options = options || {};
// for optimization purposes try to use cached mimeTree etc. if available
// If these values are missing then generate these when first time required
// So if the query is for (UID FLAGS) then mimeTree is never generated
let mimeTree = message.mimeTree;
let indexer = new Indexer(options);
// generate response object
let values = [];
query.forEach(item => {
let value = '';
switch (item.item) {
case 'uid':
value = message.uid;
break;
case 'modseq':
value = message.modseq;
break;
case 'flags':
value = message.flags;
break;
case 'internaldate':
if (!message.idate) {
message.idate = new Date();
}
value = message.idate;
break;
case 'bodystructure': {
if (message.bodystructure) {
value = message.bodystructure;
} else {
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getBodyStructure(mimeTree);
}
let walk = arr => {
arr.forEach((entry, i) => {
if (Array.isArray(entry)) {
return walk(entry);
}
if (!entry || typeof entry !== 'object') {
return;
}
let val = entry;
if (!Buffer.isBuffer(val) && val.buffer) {
val = val.buffer;
}
arr[i] = libmime.encodeWords(val.toString(), false, Infinity);
});
};
if (!options.acceptUTF8Enabled) {
walk(value);
}
break;
}
case 'envelope':
if (message.envelope) {
value = message.envelope;
} else {
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getEnvelope(mimeTree);
}
if (!options.acceptUTF8Enabled) {
// encode unicode values
// subject
value[1] = libmime.encodeWords(value[1], false, Infinity);
for (let i = 2; i < 8; i++) {
if (value[i] && Array.isArray(value[i])) {
value[i].forEach(addr => {
if (addr[0] && typeof addr[0] === 'object') {
// name
let val = addr[0];
if (!Buffer.isBuffer(val) && val.buffer) {
val = val.buffer;
}
addr[0] = libmime.encodeWords(val.toString(), false, Infinity);
}
if (addr[2] && typeof addr[2] === 'object') {
// username
let val = addr[2];
if (!Buffer.isBuffer(val) && val.buffer) {
val = val.buffer;
}
addr[2] = libmime.encodeWords(val.toString(), false, Infinity);
}
if (addr[3] && typeof addr[3] === 'object') {
// domain
let val = addr[3];
if (!Buffer.isBuffer(val) && val.buffer) {
val = val.buffer;
}
try {
addr[3] = punycode.toASCII(val.toString());
} catch (E) {
addr[3] = val.toString();
}
}
});
}
}
// libmime.encodeWords(value, false, Infinity)
}
break;
case 'rfc822':
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getContents(mimeTree);
break;
case 'rfc822.size':
if (message.size) {
value = message.size;
} else {
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getSize(mimeTree);
}
break;
case 'rfc822.header':
// Equivalent to BODY[HEADER]
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = [].concat(mimeTree.header || []).join('\r\n') + '\r\n\r\n';
break;
case 'rfc822.text':
// Equivalent to BODY[TEXT]
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getContents(mimeTree, {
path: '',
type: 'text'
});
break;
case 'body':
if (!item.hasOwnProperty('type')) {
// BODY
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getBody(mimeTree);
} else if (item.path === '' && item.type === 'content') {
// BODY[]
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getContents(mimeTree);
} else {
// BODY[SELECTOR]
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getContents(mimeTree, item);
}
if (item.partial) {
let len;
if (value && value.type === 'stream') {
value.startFrom = item.partial.startFrom;
value.maxLength = item.partial.maxLength;
len = value.expectedLength;
} else {
value = value.toString('binary').substr(item.partial.startFrom, item.partial.maxLength);
len = value.length;
}
// If start+length is larger than available value length, then do not return the length value
// Instead of BODY[]<10.20> return BODY[]<10> which means that the response is from offset 10 to the end
if (item.original.partial.length === 2 && item.partial.maxLength - item.partial.startFrom > len) {
item.original.partial.pop();
}
}
break;
}
values.push(value);
});
return values;
};