UNPKG

imapflow

Version:

IMAP Client for Node

888 lines (739 loc) 28.9 kB
/* eslint no-control-regex:0 */ 'use strict'; const libmime = require('libmime'); const { resolveCharset } = require('./charsets'); const { compiler } = require('./handler/imap-handler'); const { createHash } = require('crypto'); const { JPDecoder } = require('./jp-decoder'); const iconv = require('iconv-lite'); const FLAG_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'grey']; class AuthenticationFailure extends Error { authenticationFailed = true; } const tools = { encodePath(connection, path) { path = (path || '').toString(); if (!connection.enabled.has('UTF8=ACCEPT') && /[&\x00-\x08\x0b-\x0c\x0e-\x1f\u0080-\uffff]/.test(path)) { try { path = iconv.encode(path, 'utf-7-imap').toString(); } catch { // ignore, keep name as is } } return path; }, decodePath(connection, path) { path = (path || '').toString(); if (!connection.enabled.has('UTF8=ACCEPT') && /[&]/.test(path)) { try { path = iconv.decode(Buffer.from(path), 'utf-7-imap').toString(); } catch { // ignore, keep name as is } } return path; }, normalizePath(connection, path, skipNamespace) { if (Array.isArray(path)) { path = path.join((connection.namespace && connection.namespace.delimiter) || ''); } if (path.toUpperCase() === 'INBOX') { // inbox is not case sensitive return 'INBOX'; } // ensure namespace prefix if needed if (!skipNamespace && connection.namespace && connection.namespace.prefix && path.indexOf(connection.namespace.prefix) !== 0) { path = connection.namespace.prefix + path; } return path; }, comparePaths(connection, a, b) { if (!a || !b) { return false; } return tools.normalizePath(connection, a) === tools.normalizePath(connection, b); }, updateCapabilities(list) { let map = new Map(); if (list && Array.isArray(list)) { list.forEach(val => { if (typeof val.value !== 'string') { return false; } let capability = val.value.toUpperCase().trim(); if (capability === 'IMAP4REV1') { map.set('IMAP4rev1', true); return; } if (capability.indexOf('APPENDLIMIT=') === 0) { let splitPos = capability.indexOf('='); let appendLimit = Number(capability.substr(splitPos + 1)) || 0; map.set('APPENDLIMIT', appendLimit); return; } map.set(capability, true); }); } return map; }, AuthenticationFailure, getStatusCode(response) { return response && response.attributes && response.attributes[0] && response.attributes[0].section && response.attributes[0].section[0] && typeof response.attributes[0].section[0].value === 'string' ? response.attributes[0].section[0].value.toUpperCase().trim() : false; }, async getErrorText(response) { if (!response) { return false; } return (await compiler(response)).toString(); }, getFolderTree(folders) { let tree = { root: true, folders: [] }; let getTreeNode = parents => { let node = tree; if (!parents || !parents.length) { return node; } for (let parent of parents) { let cur = node.folders && node.folders.find(folder => folder.name === parent); if (cur) { node = cur; } else { // not yet set cur = { name: parent, folders: [] }; } } return node; }; for (let folder of folders) { let parent = getTreeNode(folder.parent); // see if entry already exists let existing = parent.folders && parent.folders.find(existing => existing.name === folder.name); if (existing) { // update values existing.name = folder.name; existing.flags = folder.flags; existing.path = folder.path; existing.subscribed = !!folder.subscribed; existing.listed = !!folder.listed; existing.status = !!folder.status; if (folder.specialUse) { existing.specialUse = folder.specialUse; } if (folder.flags.has('\\Noselect')) { existing.disabled = true; } if (folder.flags.has('\\HasChildren') && !existing.folders) { existing.folders = []; } } else { // create new let data = { name: folder.name, flags: folder.flags, path: folder.path, subscribed: !!folder.subscribed, listed: !!folder.listed, status: !!folder.status }; if (folder.delimiter) { data.delimiter = folder.delimiter; } if (folder.specialUse) { data.specialUse = folder.specialUse; } if (folder.flags.has('\\Noselect')) { data.disabled = true; } if (folder.flags.has('\\HasChildren')) { data.folders = []; } if (!parent.folders) { parent.folders = []; } parent.folders.push(data); } } return tree; }, getFlagColor(flags) { if (!flags.has('\\Flagged')) { return null; } const bit0 = flags.has('$MailFlagBit0') ? 1 : 0; const bit1 = flags.has('$MailFlagBit1') ? 2 : 0; const bit2 = flags.has('$MailFlagBit2') ? 4 : 0; const color = bit0 | bit1 | bit2; // eslint-disable-line no-bitwise return FLAG_COLORS[color] || 'red'; // default to red for the unused \b111 }, getColorFlags(color) { const colorCode = color ? FLAG_COLORS.indexOf((color || '').toString().toLowerCase().trim()) : null; if (colorCode < 0 && colorCode !== null) { return null; } const bits = []; bits[0] = colorCode & 1; // eslint-disable-line no-bitwise bits[1] = colorCode & 2; // eslint-disable-line no-bitwise bits[2] = colorCode & 4; // eslint-disable-line no-bitwise let result = { add: colorCode ? ['\\Flagged'] : [], remove: colorCode ? [] : ['\\Flagged'] }; for (let i = 0; i < bits.length; i++) { if (bits[i]) { result.add.push(`$MailFlagBit${i}`); } else { result.remove.push(`$MailFlagBit${i}`); } } return result; }, async formatMessageResponse(untagged, mailbox) { let map = {}; map.seq = Number(untagged.command); let key; let attributes = (untagged.attributes && untagged.attributes[1]) || []; for (let i = 0, len = attributes.length; i < len; i++) { let attribute = attributes[i]; if (i % 2 === 0) { key = ( await compiler({ attributes: [attribute] }) ) .toString() .toLowerCase() .replace(/<\d+(\.\d+)?>$/, ''); continue; } if (typeof key !== 'string') { // should not happen continue; } let getString = attribute => { if (!attribute) { return false; } if (typeof attribute.value === 'string') { return attribute.value; } if (Buffer.isBuffer(attribute.value)) { return attribute.value.toString(); } }; let getBuffer = attribute => { if (!attribute) { return false; } if (Buffer.isBuffer(attribute.value)) { return attribute.value; } }; let getArray = attribute => { if (Array.isArray(attribute)) { return attribute.map(entry => (entry && typeof entry.value === 'string' ? entry.value : false)).filter(entry => entry); } }; switch (key) { case 'body[]': case 'binary[]': map.source = getBuffer(attribute); break; case 'uid': map.uid = Number(getString(attribute)); if (map.uid && (!mailbox.uidNext || mailbox.uidNext <= map.uid)) { // current uidNext seems to be outdated, bump it mailbox.uidNext = map.uid + 1; } break; case 'modseq': map.modseq = BigInt(getArray(attribute)[0]); if (map.modseq && (!mailbox.highestModseq || mailbox.highestModseq < map.modseq)) { // current highestModseq seems to be outdated, bump it mailbox.highestModseq = map.modseq; } break; case 'emailid': map.emailId = getArray(attribute)[0]; break; case 'x-gm-msgid': map.emailId = getString(attribute); break; case 'threadid': map.threadId = getArray(attribute)[0]; break; case 'x-gm-thrid': map.threadId = getString(attribute); break; case 'x-gm-labels': map.labels = new Set(getArray(attribute)); break; case 'rfc822.size': map.size = Number(getString(attribute)) || 0; break; case 'flags': map.flags = new Set(getArray(attribute)); break; case 'envelope': map.envelope = tools.parseEnvelope(attribute); break; case 'bodystructure': map.bodyStructure = tools.parseBodystructure(attribute); break; case 'internaldate': { let value = getString(attribute); let date = new Date(value); if (date.toString() === 'Invalid Date') { map.internalDate = value; } else { map.internalDate = date; } break; } default: { let match = key.match(/(body|binary)\[/i); if (match) { let partKey = key.replace(/^(body|binary)\[|]$/gi, ''); partKey = partKey.replace(/\.fields.*$/g, ''); let value = getBuffer(attribute); if (partKey === 'header') { map.headers = value; break; } if (!map.bodyParts) { map.bodyParts = new Map(); } map.bodyParts.set(partKey, value); break; } break; } } } if (map.emailId || map.uid) { // define account unique ID for this email // normalize path to use ascii, so we would always get the same ID let path = mailbox.path; if (/[0x80-0xff]/.test(path)) { try { path = iconv.encode(path, 'utf-7-imap').toString(); } catch { // ignore } } map.id = map.emailId || createHash('md5') .update([path, mailbox.uidValidity?.toString() || '', map.uid.toString()].join(':')) .digest('hex'); } if (map.flags) { let flagColor = tools.getFlagColor(map.flags); if (flagColor) { map.flagColor = flagColor; } } return map; }, processName(name) { name = (name || '').toString(); if (name.length > 2 && name.at(0) === '"' && name.at(-1) === '"') { name = name.replace(/^"|"$/g, ''); } return name; }, parseEnvelope(entry) { let getStrValue = obj => { if (!obj) { return false; } if (typeof obj.value === 'string') { return obj.value; } if (Buffer.isBuffer(obj.value)) { return obj.value.toString(); } return obj.value; }; let processAddresses = function (list) { return [] .concat(list || []) .map(addr => { let address = (getStrValue(addr[2]) || '') + '@' + (getStrValue(addr[3]) || ''); if (address === '@') { address = ''; } return { name: tools.processName(libmime.decodeWords(getStrValue(addr[0]))), address }; }) .filter(addr => addr.name || addr.address); }, envelope = {}; if (entry[0] && entry[0].value) { let date = new Date(getStrValue(entry[0])); if (date.toString() === 'Invalid Date') { envelope.date = getStrValue(entry[0]); } else { envelope.date = date; } } if (entry[1] && entry[1].value) { envelope.subject = libmime.decodeWords(getStrValue(entry[1])); } if (entry[2] && entry[2].length) { envelope.from = processAddresses(entry[2]); } if (entry[3] && entry[3].length) { envelope.sender = processAddresses(entry[3]); } if (entry[4] && entry[4].length) { envelope.replyTo = processAddresses(entry[4]); } if (entry[5] && entry[5].length) { envelope.to = processAddresses(entry[5]); } if (entry[6] && entry[6].length) { envelope.cc = processAddresses(entry[6]); } if (entry[7] && entry[7].length) { envelope.bcc = processAddresses(entry[7]); } if (entry[8] && entry[8].value) { envelope.inReplyTo = (getStrValue(entry[8]) || '').toString().trim(); } if (entry[9] && entry[9].value) { envelope.messageId = (getStrValue(entry[9]) || '').toString().trim(); } return envelope; }, getStructuredParams(arr) { let key; let params = {}; [].concat(arr || []).forEach((val, j) => { if (j % 2) { params[key] = libmime.decodeWords(((val && val.value) || '').toString()); } else { key = ((val && val.value) || '').toString().toLowerCase(); } }); if (params.filename && !params['filename*'] && /^[a-z\-_0-9]+'[a-z]*'[^'\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]+/.test(params.filename)) { // seems like encoded value let [encoding, , encodedValue] = params.filename.split("'"); if (resolveCharset(encoding)) { params['filename*'] = `${encoding}''${encodedValue}`; } } // preprocess values Object.keys(params).forEach(key => { let actualKey; let nr; let value; let match = key.match(/\*((\d+)\*?)?$/); if (!match) { // nothing to do here, does not seem like a continuation param return; } actualKey = key.substr(0, match.index).toLowerCase(); nr = Number(match[2]) || 0; if (!params[actualKey] || typeof params[actualKey] !== 'object') { params[actualKey] = { charset: false, values: [] }; } value = params[key]; if (nr === 0 && match[0].charAt(match[0].length - 1) === '*' && (match = value.match(/^([^']*)'[^']*'(.*)$/))) { params[actualKey].charset = match[1] || 'utf-8'; value = match[2]; } params[actualKey].values.push({ nr, value }); // remove the old reference delete params[key]; }); // concatenate split rfc2231 strings and convert encoded strings to mime encoded words Object.keys(params).forEach(key => { let value; if (params[key] && Array.isArray(params[key].values)) { value = params[key].values .sort((a, b) => a.nr - b.nr) .map(val => (val && val.value) || '') .join(''); if (params[key].charset) { // convert "%AB" to "=?charset?Q?=AB?=" and then to unicode params[key] = libmime.decodeWords( '=?' + params[key].charset + '?Q?' + value // fix invalidly encoded chars .replace(/[=?_\s]/g, s => { let c = s.charCodeAt(0).toString(16); if (s === ' ') { return '_'; } else { return '%' + (c.length < 2 ? '0' : '') + c; } }) // change from urlencoding to percent encoding .replace(/%/g, '=') + '?=' ); } else { params[key] = libmime.decodeWords(value); } } }); return params; }, parseBodystructure(entry) { let walk = (node, path) => { path = path || []; let curNode = {}, i = 0, part = 0; if (path.length) { curNode.part = path.join('.'); } // multipart if (Array.isArray(node[0])) { curNode.childNodes = []; while (Array.isArray(node[i])) { curNode.childNodes.push(walk(node[i], path.concat(++part))); i++; } // multipart type curNode.type = 'multipart/' + ((node[i++] || {}).value || '').toString().toLowerCase(); // extension data (not available for BODY requests) // body parameter parenthesized list if (i < node.length - 1) { if (node[i]) { curNode.parameters = tools.getStructuredParams(node[i]); } i++; } } else { // content type curNode.type = [((node[i++] || {}).value || '').toString().toLowerCase(), ((node[i++] || {}).value || '').toString().toLowerCase()].join('/'); // body parameter parenthesized list if (node[i]) { curNode.parameters = tools.getStructuredParams(node[i]); } i++; // id if (node[i]) { curNode.id = ((node[i] || {}).value || '').toString(); } i++; // description if (node[i]) { curNode.description = ((node[i] || {}).value || '').toString(); } i++; // encoding if (node[i]) { curNode.encoding = ((node[i] || {}).value || '').toString().toLowerCase(); } i++; // size if (node[i]) { curNode.size = Number((node[i] || {}).value || 0) || 0; } i++; if (curNode.type === 'message/rfc822') { // message/rfc adds additional envelope, bodystructure and line count values // envelope if (node[i]) { curNode.envelope = tools.parseEnvelope([].concat(node[i] || [])); } i++; if (node[i]) { curNode.childNodes = [ // rfc822 bodyparts share the same path, difference is between MIME and HEADER // path.MIME returns message/rfc822 header // path.HEADER returns inlined message header walk(node[i], path) ]; } i++; // line count if (node[i]) { curNode.lineCount = Number((node[i] || {}).value || 0) || 0; } i++; } if (/^text\//.test(curNode.type)) { // text/* adds additional line count value // NB! some less known servers do not include the line count value // length should be 12+ if (node.length === 11 && Array.isArray(node[i + 1]) && !Array.isArray(node[i + 2])) { // invalid structure, disposition params are shifted } else { // correct structure, line count number is provided if (node[i]) { // line count curNode.lineCount = Number((node[i] || {}).value || 0) || 0; } i++; } } // extension data (not available for BODY requests) // md5 if (i < node.length - 1) { if (node[i]) { curNode.md5 = ((node[i] || {}).value || '').toString().toLowerCase(); } i++; } } // the following are shared extension values (for both multipart and non-multipart parts) // not available for BODY requests // body disposition if (i < node.length - 1) { if (Array.isArray(node[i]) && node[i].length) { curNode.disposition = ((node[i][0] || {}).value || '').toString().toLowerCase(); if (Array.isArray(node[i][1])) { curNode.dispositionParameters = tools.getStructuredParams(node[i][1]); } } i++; } // body language if (i < node.length - 1) { if (node[i]) { curNode.language = [].concat(node[i] || []).map(val => ((val && val.value) || '').toString().toLowerCase()); } i++; } // body location // NB! defined as a "string list" in RFC3501 but replaced in errata document with "string" // Errata: http://www.rfc-editor.org/errata_search.php?rfc=3501 if (i < node.length - 1) { if (node[i]) { curNode.location = ((node[i] || {}).value || '').toString(); } i++; } return curNode; }; return walk(entry); }, isDate(obj) { return Object.prototype.toString.call(obj) === '[object Date]'; }, formatDate(value) { if (typeof value === 'string') { value = new Date(value); } if (Object.prototype.toString(value) !== '[object Object]' || value.toString() === 'Invalid Date') { return; } value = value.toISOString().substr(0, 10); value = value.split('-'); value.reverse(); let months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; value[1] = months[Number(value[1]) - 1]; return value.join('-'); }, formatDateTime(value) { if (!value) { return; } if (typeof value === 'string') { value = new Date(value); } if (Object.prototype.toString(value) !== '[object Object]' || value.toString() === 'Invalid Date') { return; } let dateStr = tools.formatDate(value).replace(/^0/, ' '); //starts with date-day-fixed with leading 0 replaced by SP let timeStr = value.toISOString().substr(11, 8); return `${dateStr} ${timeStr} +0000`; }, formatFlag(flag) { switch (flag.toLowerCase()) { case '\\recent': // can not set or remove return false; case '\\seen': case '\\answered': case '\\flagged': case '\\deleted': case '\\draft': // can not set or remove return flag.toLowerCase().replace(/^\\./, c => c.toUpperCase()); } return flag; }, canUseFlag(mailbox, flag) { return !mailbox || !mailbox.permanentFlags || mailbox.permanentFlags.has('\\*') || mailbox.permanentFlags.has(flag); }, expandRange(range) { return range.split(',').flatMap(entry => { entry = entry.trim(); let colon = entry.indexOf(':'); if (colon < 0) { return Number(entry) || 0; } let first = Number(entry.substr(0, colon)) || 0; let second = Number(entry.substr(colon + 1)) || 0; if (first === second) { return first; } let list = []; if (first < second) { for (let i = first; i <= second; i++) { list.push(i); } } else { for (let i = first; i >= second; i--) { list.push(i); } } return list; }); }, getDecoder(charset) { charset = (charset || 'ascii').toString().trim().toLowerCase(); if (/^jis|^iso-?2022-?jp|^EUCJP/i.test(charset)) { // special case not supported by iconv-lite return new JPDecoder(charset); } return iconv.decodeStream(charset); }, packMessageRange(list) { if (!Array.isArray(list)) { list = [].concat(list || []); } if (!list.length) { return ''; } list.sort((a, b) => a - b); let last = list[list.length - 1]; let result = [[last]]; for (let i = list.length - 2; i >= 0; i--) { if (list[i] === list[i + 1] - 1) { result[0].unshift(list[i]); continue; } result.unshift([list[i]]); } result = result.map(item => { if (item.length === 1) { return item[0]; } return item.shift() + ':' + item.pop(); }); return result.join(','); } }; module.exports = tools;