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

752 lines
21 KiB
JavaScript
Raw Normal View History

2017-03-06 05:45:50 +08:00
'use strict';
const Indexer = require('./indexer/indexer');
const utf7 = require('utf7').imap;
const libmime = require('libmime');
const punycode = require('punycode');
2017-03-06 05:45:50 +08:00
2017-03-30 18:25:42 +08:00
module.exports.systemFlagsFormatted = ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen'];
2017-03-06 05:45:50 +08:00
module.exports.systemFlags = ['\\answered', '\\flagged', '\\draft', '\\deleted', '\\seen'];
module.exports.fetchSchema = {
2017-06-03 14:51:58 +08:00
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'
}
],
2017-03-06 05:45:50 +08:00
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'],
2017-06-03 14:51:58 +08:00
modseq: [['string', 'string', 'number'], ['number']],
2017-03-06 05:45:50 +08:00
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
*/
2017-06-03 14:51:58 +08:00
module.exports.validateSequnce = function(range) {
2017-03-06 05:45:50 +08:00
return !!(range.length && /^(\d+|\*)(:\d+|:\*)?(,(\d+|\*)(:\d+|:\*)?)*$/.test(range));
};
2017-06-03 14:51:58 +08:00
module.exports.normalizeMailbox = function(mailbox, utf7Encoded) {
2017-03-06 05:45:50 +08:00
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));
}
2017-03-06 05:45:50 +08:00
mailbox = parts.join('/');
return mailbox;
};
2017-06-03 14:51:58 +08:00
module.exports.generateFolderListing = function(folders, skipHierarchy) {
2017-03-06 05:45:50 +08:00
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 = {
2017-05-16 19:37:54 +08:00
// flags array is used to store permanentflags
//flags: [].concat(folder.flags || []),
flags: [],
2017-03-06 05:45:50 +08:00
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;
};
2017-06-03 14:51:58 +08:00
module.exports.filterFolders = function(folders, query) {
2017-03-06 05:45:50 +08:00
query = query
// remove excess * and %
2017-06-03 14:51:58 +08:00
.replace(/\*\*+/g, '*')
.replace(/%%+/g, '%')
2017-03-06 05:45:50 +08:00
// escape special characters
.replace(/([\\^$+?!.():=[\]|,-])/g, '\\$1')
2017-03-06 05:45:50 +08:00
// setup *
.replace(/[*]/g, '.*')
// setup %
.replace(/[%]/g, '[^/]*');
2017-03-06 05:45:50 +08:00
let regex = new RegExp('^' + query + '$', '');
return folders.filter(folder => !!regex.test(folder.path));
};
2017-06-03 14:51:58 +08:00
module.exports.getMessageRange = function(uidList, range, isUid) {
2017-03-06 05:45:50 +08:00
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;
2017-06-03 14:51:58 +08:00
to = Number((to === '*' && total) || to) || from;
2017-03-06 05:45:50 +08:00
if (nr >= Math.min(from, to) && nr <= Math.max(from, to)) {
return true;
}
}
return false;
};
for (i = 0, len = uidList.length; i < len; i++) {
2017-03-06 05:45:50 +08:00
if (uidList[i] > maxUid) {
maxUid = uidList[i];
}
}
for (i = 0, len = uidList.length; i < len; i++) {
2017-03-06 05:45:50 +08:00
uid = uidList[i] || 1;
if (inRange(isUid ? uid : i + 1, rangeParts, isUid ? maxUid : totalMessages)) {
result.push(uidList[i]);
}
}
return result;
};
2017-06-03 14:51:58 +08:00
module.exports.packMessageRange = function(uidList) {
2017-03-06 05:45:50 +08:00
if (!Array.isArray(uidList)) {
uidList = [].concat(uidList || []);
}
if (!uidList.length) {
return '';
}
2017-06-03 14:51:58 +08:00
uidList.sort((a, b) => a - b);
2017-03-06 05:45:50 +08:00
let last = uidList[uidList.length - 1];
2017-06-03 14:51:58 +08:00
let result = [[last]];
2017-03-06 05:45:50 +08:00
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
*/
2017-06-03 14:51:58 +08:00
module.exports.formatInternalDate = function(date) {
2017-03-06 05:45:50 +08:00
let day = date.getUTCDate(),
2017-06-03 14:51:58 +08:00
month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][date.getUTCMonth()],
2017-03-06 05:45:50 +08:00
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;
2017-06-03 14:51:58 +08:00
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
);
2017-03-06 05:45:50 +08:00
};
/**
* 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
*/
2017-06-03 14:51:58 +08:00
module.exports.getQueryResponse = function(query, message, options) {
2017-03-27 04:58:05 +08:00
options = options || {};
2017-03-06 05:45:50 +08:00
// 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':
2017-04-13 16:35:39 +08:00
if (!message.idate) {
message.idate = new Date();
2017-03-06 05:45:50 +08:00
}
2017-04-13 16:35:39 +08:00
value = message.idate;
2017-03-06 05:45:50 +08:00
break;
2017-06-03 14:51:58 +08:00
case 'bodystructure': {
if (message.bodystructure) {
value = message.bodystructure;
} else {
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
2017-06-03 14:51:58 +08:00
value = indexer.getBodyStructure(mimeTree);
}
2017-03-06 05:45:50 +08:00
2017-06-03 14:51:58 +08:00
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);
});
};
2017-06-03 14:51:58 +08:00
if (!options.acceptUTF8Enabled) {
walk(value);
}
2017-06-03 14:51:58 +08:00
break;
}
2017-03-06 05:45:50 +08:00
case 'envelope':
2017-03-10 21:20:13 +08:00
if (message.envelope) {
value = message.envelope;
} else {
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getEnvelope(mimeTree);
2017-03-06 05:45:50 +08:00
}
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)
}
2017-03-06 05:45:50 +08:00
break;
case 'rfc822':
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getContents(mimeTree);
break;
case 'rfc822.size':
2017-03-10 21:20:13 +08:00
if (message.size) {
value = message.size;
} else {
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getSize(mimeTree);
2017-03-06 05:45:50 +08:00
}
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
2017-06-03 14:51:58 +08:00
if (item.original.partial.length === 2 && item.partial.maxLength - item.partial.startFrom > len) {
2017-03-06 05:45:50 +08:00
item.original.partial.pop();
}
}
break;
}
values.push(value);
});
return values;
};
/**
* Builds and emits an untagged CAPABILITY response depending on current state
*
* @param {Object} connection IMAP connection object
*/
module.exports.sendCapabilityResponse = connection => {
let capabilities = [];
if (!connection.secure) {
if (!connection._server.options.disableSTARTTLS) {
capabilities.push('STARTTLS');
if (!connection._server.options.ignoreSTARTTLS) {
capabilities.push('LOGINDISABLED');
}
}
}
if (connection.state === 'Not Authenticated') {
capabilities.push('AUTH=PLAIN');
capabilities.push('AUTH=PLAIN-CLIENTTOKEN');
capabilities.push('SASL-IR');
capabilities.push('ENABLE');
capabilities.push('ID');
capabilities.push('UNSELECT');
capabilities.push('IDLE');
capabilities.push('NAMESPACE');
capabilities.push('QUOTA');
capabilities.push('XLIST');
capabilities.push('CHILDREN');
} else {
capabilities.push('ID');
capabilities.push('UNSELECT');
capabilities.push('IDLE');
capabilities.push('NAMESPACE');
capabilities.push('QUOTA');
capabilities.push('XLIST');
capabilities.push('CHILDREN');
capabilities.push('SPECIAL-USE');
capabilities.push('UIDPLUS');
capabilities.push('UNSELECT');
capabilities.push('ENABLE');
capabilities.push('CONDSTORE');
capabilities.push('UTF8=ACCEPT');
capabilities.push('MOVE');
capabilities.push('COMPRESS=DEFLATE');
if (connection._server.options.maxMessage) {
capabilities.push('APPENDLIMIT=' + connection._server.options.maxMessage);
}
}
capabilities.sort((a, b) => a.localeCompare(b));
connection.send('* CAPABILITY ' + ['IMAP4rev1'].concat(capabilities).join(' '));
};