"use strict"; const Promise = require("bluebird"); const defaultValue = require("default-value"); const asExpression = require("as-expression"); const matchValue = require("match-value"); const unreachable = require("@joepie91/unreachable"); const { validateArguments } = require("@validatem/core"); const required = require("@validatem/required"); const anyProperty = require("@validatem/any-property"); const isString = require("@validatem/is-string"); const isFunction = require("@validatem/is-function"); const { command, unsafeRaw, already7Bit } = require("./util/command"); const pInterval = require("./util/p-interval"); const createFetchTaskTracker = require("./util/fetch-task"); const createBoxTreeBuilder = require("./util/box-tree-builder"); const ensureArray = require("./util/ensure-array"); var tls = require('tls'), Socket = require('net').Socket, EventEmitter = require('events').EventEmitter, inherits = require('util').inherits, inspect = require('util').inspect, isDate = require('util').isDate, utf7 = require('utf7').imap; var Parser = require('./Parser').Parser, parseExpr = require('./Parser').parseExpr, parseHeader = require('./Parser').parseHeader; var MAX_INT = 9007199254740992, KEEPALIVE_INTERVAL = 10000, MAX_IDLE_WAIT = 300000, // 5 minutes MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], FETCH_ATTR_MAP = { 'RFC822.SIZE': 'size', 'BODY': 'struct', 'BODYSTRUCTURE': 'struct', 'ENVELOPE': 'envelope', 'INTERNALDATE': 'date' }, SPECIAL_USE_ATTRIBUTES = [ '\\All', '\\Archive', '\\Drafts', '\\Flagged', '\\Important', '\\Junk', '\\Sent', '\\Trash' ], CRLF = '\r\n', RE_CMD = /^([^ ]+)(?: |$)/, RE_UIDCMD_HASRESULTS = /^UID (?:FETCH|SEARCH|SORT)/, RE_IDLENOOPRES = /^(IDLE|NOOP) /, RE_OPENBOX = /^EXAMINE|SELECT$/, RE_BODYPART = /^BODY\[/, RE_INVALID_KW_CHARS = /[\(\)\{\\\"\]\%\*\x00-\x20\x7F]/, RE_NUM_RANGE = /^(?:[\d]+|\*):(?:[\d]+|\*)$/, RE_BACKSLASH = /\\/g, RE_DBLQUOTE = /"/g, RE_ESCAPE = /\\\\/g, RE_INTEGER = /^\d+$/; function Connection(config) { if (!(this instanceof Connection)) return new Connection(config); EventEmitter.call(this); config || (config = {}); this._config = { localAddress: config.localAddress, socket: config.socket, socketTimeout: config.socketTimeout || 0, host: config.host || 'localhost', port: config.port || 143, tls: config.tls, tlsOptions: config.tlsOptions, autotls: config.autotls, user: config.user, password: config.password, xoauth: config.xoauth, xoauth2: config.xoauth2, connTimeout: config.connTimeout || 10000, authTimeout: config.authTimeout || 5000, keepalive: (config.keepalive === undefined || config.keepalive === null ? true : config.keepalive) }; this._sock = config.socket || undefined; this._tagcount = 0; this._connectionTimeout = undefined; this._authenticationTimeout = undefined; this._queue = []; this._box = undefined; this._idle = { started: undefined, enabled: false }; this._parser = undefined; this._curReq = undefined; this.delimiter = undefined; this.namespaces = undefined; this.state = 'disconnected'; this.debug = config.debug; } inherits(Connection, EventEmitter); Connection.prototype.connect = function() { var config = this._config, self = this, socket, parser, tlsOptions; socket = config.socket || new Socket(); socket.setKeepAlive(true); this._sock = undefined; this._tagcount = 0; this._connectionTimeout = undefined; this._authenticationTimeout = undefined; this._queue = []; this._box = undefined; this._idle = { started: undefined, enabled: false }; this._parser = undefined; this._curReq = undefined; this.delimiter = undefined; this.namespaces = undefined; this.state = 'disconnected'; this._cancelKeepaliveTimer = function () {}; if (config.tls) { tlsOptions = {}; tlsOptions.host = config.host; // Host name may be overridden the tlsOptions for (var k in config.tlsOptions) tlsOptions[k] = config.tlsOptions[k]; tlsOptions.socket = socket; } if (config.tls) this._sock = tls.connect(tlsOptions, onconnect); else { socket.once('connect', onconnect); this._sock = socket; } function onconnect() { clearTimeout(self._connectionTimeout); self.state = 'connected'; self.debug && self.debug('[connection] Connected to host'); self._authenticationTimeout = setTimeout(function() { var err = new Error('Timed out while authenticating with server'); err.source = 'timeout-auth'; self.emit('error', err); socket.destroy(); }, config.authTimeout); } this._onError = function(err) { clearTimeout(self._connectionTimeout); clearTimeout(self._authenticationTimeout); self.debug && self.debug('[connection] Error: ' + err); err.source = 'socket'; self.emit('error', err); }; this._sock.on('error', this._onError); this._teardownConnection = function () { clearTimeout(self._connectionTimeout); clearTimeout(self._authenticationTimeout); self._cancelKeepaliveTimer(); self.state = 'disconnected'; }; this._onSocketTimeout = function() { self._teardownConnection(); self.debug && self.debug('[connection] Socket timeout'); var err = new Error('Socket timed out while talking to server'); err.source = 'socket-timeout'; self.emit('error', err); socket.destroy(); }; this._sock.on('timeout', this._onSocketTimeout); socket.setTimeout(config.socketTimeout); socket.once('close', function(had_err) { self._teardownConnection(); self.debug && self.debug('[connection] Closed'); self.emit('close', had_err); }); socket.once('end', function() { self._teardownConnection(); self.debug && self.debug('[connection] Ended'); self.emit('end'); }); this._parser = parser = new Parser(this._sock, this.debug); parser.on('untagged', function(info) { self._resUntagged(info); }); parser.on('tagged', function(info) { self._resTagged(info); }); parser.on('body', function(stream, info) { var task = self._curReq.fetchCache.get(info.seqno), remainingKeys; if (task == null) { task = self._curReq.fetchCache.create(info.seqno, self._curReq.fetching.slice()); self._curReq.bodyEmitter.emit('message', task.emitter, info.seqno); } remainingKeys = task.getRemainingKeys(); // FIXME: Refactor below // here we compare the parsed version of the expression inside BODY[] // because 'HEADER.FIELDS (TO FROM)' really is equivalent to // 'HEADER.FIELDS ("TO" "FROM")' and some servers will actually send the // quoted form even if the client did not use quotes var thisbody = parseExpr(info.which); for (var i = 0, len = remainingKeys.length; i < len; ++i) { if (_deepEqual(thisbody, remainingKeys[i])) { remainingKeys.splice(i, 1); task.emitter.emit('body', stream, info); return; } } stream.resume(); // a body we didn't ask for? }); parser.on('continue', function(info) { var type = self._curReq.type; if (type === 'IDLE') { if (self._queue.length && self._idle.started === 0 && self._curReq && self._curReq.type === 'IDLE' && self._sock && self._sock.writable && !self._idle.enabled) { self.debug && self.debug('=> DONE'); self._sock.write('DONE' + CRLF); return; } // now idling self._idle.started = Date.now(); } else if (/^AUTHENTICATE XOAUTH/.test(self._curReq.fullcmd)) { self._curReq.oauthError = Buffer.from(info.text, 'base64').toString('utf8'); self.debug && self.debug('=> ' + inspect(CRLF)); self._sock.write(CRLF); } else if (type === 'APPEND') { self._sockWriteAppendData(self._curReq.appendData); } else if (self._curReq.lines && self._curReq.lines.length) { var line = self._curReq.lines.shift() + '\r\n'; self.debug && self.debug('=> ' + inspect(line)); self._sock.write(line, 'binary'); } }); parser.on('other', function(line) { var m; if (m = RE_IDLENOOPRES.exec(line)) { // no longer idling self._idle.enabled = false; self._idle.started = undefined; self._cancelKeepaliveTimer(); self._curReq = undefined; if (self._queue.length === 0 && self._config.keepalive && self.state === 'authenticated' && !self._idle.enabled) { self._idle.enabled = true; if (m[1] === 'NOOP') self._doKeepaliveTimer(); else self._doKeepaliveTimer(true); } self._processQueue(); } }); this._connectionTimeout = setTimeout(function() { var err = new Error('Timed out while connecting to server'); err.source = 'timeout'; self.emit('error', err); socket.destroy(); }, config.connTimeout); socket.connect({ port: config.port, host: config.host, localAddress: config.localAddress }); }; Connection.prototype.serverSupports = function(cap) { return (this._caps && this._caps.indexOf(cap) > -1); }; Connection.prototype.destroy = function() { this._queue = []; this._curReq = undefined; if (this._sock != null) { this._sock.end(); } }; Connection.prototype.end = function() { return Promise.try(() => { return this._enqueue2(command`LOGOUT`); }).then(() => { return this.destroy(); }); }; Connection.prototype.append = function(data, options, cb) { var literal = this.serverSupports('LITERAL+'); if (typeof options === 'function') { cb = options; options = undefined; } options = options || {}; if (!options.mailbox) { if (!this._box) throw new Error('No mailbox specified or currently selected'); else options.mailbox = this._box.name; } var cmd = 'APPEND "' + escape(utf7.encode(''+options.mailbox)) + '"'; if (options.flags) { if (!Array.isArray(options.flags)) options.flags = [options.flags]; if (options.flags.length > 0) { for (var i = 0, len = options.flags.length; i < len; ++i) { if (options.flags[i][0] !== '$' && options.flags[i][0] !== '\\') options.flags[i] = '\\' + options.flags[i]; } cmd += ' (' + options.flags.join(' ') + ')'; } } if (options.date) { if (!isDate(options.date)) throw new Error('`date` is not a Date object'); cmd += ' "'; cmd += options.date.getDate(); cmd += '-'; cmd += MONTHS[options.date.getMonth()]; cmd += '-'; cmd += options.date.getFullYear(); cmd += ' '; cmd += ('0' + options.date.getHours()).slice(-2); cmd += ':'; cmd += ('0' + options.date.getMinutes()).slice(-2); cmd += ':'; cmd += ('0' + options.date.getSeconds()).slice(-2); cmd += ((options.date.getTimezoneOffset() > 0) ? ' -' : ' +' ); cmd += ('0' + (-options.date.getTimezoneOffset() / 60)).slice(-2); cmd += ('0' + (-options.date.getTimezoneOffset() % 60)).slice(-2); cmd += '"'; } cmd += ' {'; cmd += (Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data)); cmd += (literal ? '+' : '') + '}'; this._enqueue(cmd, cb); if (literal) this._queue[this._queue.length - 1].literalAppendData = data; else this._queue[this._queue.length - 1].appendData = data; }; Connection.prototype.getSpecialUseBoxes = function(cb) { this._enqueue('XLIST "" "*"', cb); }; Connection.prototype.getBoxes = function(namespace, cb) { if (typeof namespace === 'function') { cb = namespace; namespace = ''; } namespace = escape(utf7.encode(''+namespace)); this._enqueue('LIST "' + namespace + '" "*"', cb); }; Connection.prototype.id = function(identification, cb) { if (!this.serverSupports('ID')) throw new Error('Server does not support ID'); var cmd = 'ID'; if ((identification === null) || (Object.keys(identification).length === 0)) cmd += ' NIL'; else { if (Object.keys(identification).length > 30) throw new Error('Max allowed number of keys is 30'); var kv = []; for (var k in identification) { if (Buffer.byteLength(k) > 30) throw new Error('Max allowed key length is 30'); if (Buffer.byteLength(identification[k]) > 1024) throw new Error('Max allowed value length is 1024'); kv.push('"' + escape(k) + '"'); kv.push('"' + escape(identification[k]) + '"'); } cmd += ' (' + kv.join(' ') + ')'; } this._enqueue(cmd, cb); }; Connection.prototype.openBox = function(name, readOnly, cb) { if (this.state !== 'authenticated') throw new Error('Not authenticated'); if (typeof readOnly === 'function') { cb = readOnly; readOnly = false; } name = ''+name; var encname = escape(utf7.encode(name)), cmd = (readOnly ? 'EXAMINE' : 'SELECT'), self = this; cmd += ' "' + encname + '"'; if (this.serverSupports('CONDSTORE')) cmd += ' (CONDSTORE)'; this._enqueue(cmd, function(err) { if (err) { self._box = undefined; cb(err); } else { self._box.name = name; cb(err, self._box); } }); }; Connection.prototype.closeBox = function(shouldExpunge, cb) { if (this._box === undefined) throw new Error('No mailbox is currently selected'); var self = this; if (typeof shouldExpunge === 'function') { cb = shouldExpunge; shouldExpunge = true; } if (shouldExpunge) { this._enqueue('CLOSE', function(err) { if (!err) self._box = undefined; cb(err); }); } else { if (this.serverSupports('UNSELECT')) { // use UNSELECT if available, as it claims to be "cleaner" than the // alternative "hack" this._enqueue('UNSELECT', function(err) { if (!err) self._box = undefined; cb(err); }); } else { // "HACK": close the box without expunging by attempting to SELECT a // non-existent mailbox var badbox = 'NODEJSIMAPCLOSINGBOX' + Date.now(); this._enqueue('SELECT "' + badbox + '"', function(err) { self._box = undefined; cb(); }); } } }; Connection.prototype.addBox = function(name, cb) { this._enqueue('CREATE "' + escape(utf7.encode(''+name)) + '"', cb); }; Connection.prototype.delBox = function(name, cb) { this._enqueue('DELETE "' + escape(utf7.encode(''+name)) + '"', cb); }; Connection.prototype.renameBox = function(oldname, newname, cb) { var encoldname = escape(utf7.encode(''+oldname)), encnewname = escape(utf7.encode(''+newname)), self = this; this._enqueue('RENAME "' + encoldname + '" "' + encnewname + '"', function(err) { if (err) return cb(err); if (self._box && self._box.name === oldname && oldname.toUpperCase() !== 'INBOX') { self._box.name = newname; cb(err, self._box); } else cb(); } ); }; Connection.prototype.subscribeBox = function(name, cb) { this._enqueue('SUBSCRIBE "' + escape(utf7.encode(''+name)) + '"', cb); }; Connection.prototype.unsubscribeBox = function(name, cb) { this._enqueue('UNSUBSCRIBE "' + escape(utf7.encode(''+name)) + '"', cb); }; Connection.prototype.getSubscribedBoxes = function(namespace, cb) { if (typeof namespace === 'function') { cb = namespace; namespace = ''; } namespace = escape(utf7.encode(''+namespace)); this._enqueue('LSUB "' + namespace + '" "*"', cb); }; Connection.prototype.status = function(boxName, cb) { if (this._box && this._box.name === boxName) throw new Error('Cannot call status on currently selected mailbox'); boxName = escape(utf7.encode(''+boxName)); var info = [ 'MESSAGES', 'RECENT', 'UNSEEN', 'UIDVALIDITY', 'UIDNEXT' ]; if (this.serverSupports('CONDSTORE')) info.push('HIGHESTMODSEQ'); info = info.join(' '); this._enqueue('STATUS "' + boxName + '" (' + info + ')', cb); }; Connection.prototype.expunge = function(uids, cb) { if (typeof uids === 'function') { cb = uids; uids = undefined; } if (uids !== undefined) { if (!Array.isArray(uids)) uids = [uids]; validateUIDList(uids); if (uids.length === 0) throw new Error('Empty uid list'); uids = uids.join(','); if (!this.serverSupports('UIDPLUS')) throw new Error('Server does not support this feature (UIDPLUS)'); this._enqueue('UID EXPUNGE ' + uids, cb); } else this._enqueue('EXPUNGE', cb); }; Connection.prototype.search = function(criteria, cb) { this._search('UID ', criteria, cb); }; Connection.prototype._search = function(which, criteria, cb) { if (this._box === undefined) throw new Error('No mailbox is currently selected'); else if (!Array.isArray(criteria)) throw new Error('Expected array for search criteria'); var cmd = which + 'SEARCH', info = { hasUTF8: false /*output*/ }, query = buildSearchQuery(criteria, this._caps, info), lines; if (info.hasUTF8) { cmd += ' CHARSET UTF-8'; lines = query.split(CRLF); query = lines.shift(); } cmd += query; this._enqueue(cmd, cb); if (info.hasUTF8) { var req = this._queue[this._queue.length - 1]; req.lines = lines; } }; Connection.prototype.addFlags = function(uids, flags, cb) { this._store('UID ', uids, { mode: '+', flags: flags }, cb); }; Connection.prototype.delFlags = function(uids, flags, cb) { this._store('UID ', uids, { mode: '-', flags: flags }, cb); }; Connection.prototype.setFlags = function(uids, flags, cb) { this._store('UID ', uids, { mode: '', flags: flags }, cb); }; Connection.prototype.addKeywords = function(uids, keywords, cb) { this._store('UID ', uids, { mode: '+', keywords: keywords }, cb); }; Connection.prototype.delKeywords = function(uids, keywords, cb) { this._store('UID ', uids, { mode: '-', keywords: keywords }, cb); }; Connection.prototype.setKeywords = function(uids, keywords, cb) { this._store('UID ', uids, { mode: '', keywords: keywords }, cb); }; Connection.prototype._store = function(which, uids, cfg, cb) { var mode = cfg.mode, isFlags = (cfg.flags !== undefined), items = (isFlags ? cfg.flags : cfg.keywords); if (this._box === undefined) throw new Error('No mailbox is currently selected'); else if (uids === undefined) throw new Error('No messages specified'); if (!Array.isArray(uids)) uids = [uids]; validateUIDList(uids); if (uids.length === 0) { throw new Error('Empty ' + (which === '' ? 'sequence number' : 'uid') + 'list'); } if ((!Array.isArray(items) && typeof items !== 'string') || (Array.isArray(items) && items.length === 0)) throw new Error((isFlags ? 'Flags' : 'Keywords') + ' argument must be a string or a non-empty Array'); if (!Array.isArray(items)) items = [items]; for (var i = 0, len = items.length; i < len; ++i) { if (isFlags) { if (items[i][0] !== '\\') items[i] = '\\' + items[i]; } else { // keyword contains any char except control characters (%x00-1F and %x7F) // and: '(', ')', '{', ' ', '%', '*', '\', '"', ']' if (RE_INVALID_KW_CHARS.test(items[i])) { throw new Error('The keyword "' + items[i] + '" contains invalid characters'); } } } items = items.join(' '); uids = uids.join(','); var modifiers = ''; if (cfg.modseq !== undefined && !this._box.nomodseq) modifiers += 'UNCHANGEDSINCE ' + cfg.modseq + ' '; this._enqueue(which + 'STORE ' + uids + ' ' + modifiers + mode + 'FLAGS.SILENT (' + items + ')', cb); }; Connection.prototype.copy = function(uids, boxTo, cb) { this._copy('UID ', uids, boxTo, cb); }; Connection.prototype._copy = function(which, uids, boxTo, cb) { if (this._box === undefined) throw new Error('No mailbox is currently selected'); if (!Array.isArray(uids)) uids = [uids]; validateUIDList(uids); if (uids.length === 0) { throw new Error('Empty ' + (which === '' ? 'sequence number' : 'uid') + 'list'); } boxTo = escape(utf7.encode(''+boxTo)); this._enqueue(which + 'COPY ' + uids.join(',') + ' "' + boxTo + '"', cb); }; Connection.prototype.move = function(uids, boxTo, cb) { this._move('UID ', uids, boxTo, cb); }; Connection.prototype._move = function(which, uids, boxTo, cb) { if (this._box === undefined) throw new Error('No mailbox is currently selected'); if (this.serverSupports('MOVE')) { if (!Array.isArray(uids)) uids = [uids]; validateUIDList(uids); if (uids.length === 0) { throw new Error('Empty ' + (which === '' ? 'sequence number' : 'uid') + 'list'); } uids = uids.join(','); boxTo = escape(utf7.encode(''+boxTo)); this._enqueue(which + 'MOVE ' + uids + ' "' + boxTo + '"', cb); } else if (this._box.permFlags.indexOf('\\Deleted') === -1 && this._box.flags.indexOf('\\Deleted') === -1) { throw new Error('Cannot move message: ' + 'server does not allow deletion of messages'); } else { var deletedUIDs, task = 0, self = this; this._copy(which, uids, boxTo, function ccb(err, info) { if (err) return cb(err, info); if (task === 0 && which && self.serverSupports('UIDPLUS')) { // UIDPLUS gives us a 'UID EXPUNGE n' command to expunge a subset of // messages with the \Deleted flag set. This allows us to skip some // actions. task = 2; } // Make sure we don't expunge any messages marked as Deleted except the // one we are moving if (task === 0) { self.search(['DELETED'], function(e, result) { ++task; deletedUIDs = result; ccb(e, info); }); } else if (task === 1) { if (deletedUIDs.length) { self.delFlags(deletedUIDs, '\\Deleted', function(e) { ++task; ccb(e, info); }); } else { ++task; ccb(err, info); } } else if (task === 2) { var cbMarkDel = function(e) { ++task; ccb(e, info); }; if (which) self.addFlags(uids, '\\Deleted', cbMarkDel); else self.seq.addFlags(uids, '\\Deleted', cbMarkDel); } else if (task === 3) { if (which && self.serverSupports('UIDPLUS')) { self.expunge(uids, function(e) { cb(e, info); }); } else { self.expunge(function(e) { ++task; ccb(e, info); }); } } else if (task === 4) { if (deletedUIDs.length) { self.addFlags(deletedUIDs, '\\Deleted', function(e) { cb(e, info); }); } else cb(err, info); } }); } }; Connection.prototype.fetch = function(uids, options) { return this._fetch('UID ', uids, options); }; Connection.prototype._fetch = function(which, uids, options) { if (uids === undefined || uids === null || (Array.isArray(uids) && uids.length === 0)) throw new Error('Nothing to fetch'); if (!Array.isArray(uids)) uids = [uids]; validateUIDList(uids); if (uids.length === 0) { throw new Error('Empty ' + (which === '' ? 'sequence number' : 'uid') + 'list'); } uids = uids.join(','); var cmd = which + 'FETCH ' + uids + ' (', fetching = [], i, len, key; if (this.serverSupports('X-GM-EXT-1')) { fetching.push('X-GM-THRID'); fetching.push('X-GM-MSGID'); fetching.push('X-GM-LABELS'); } if (this.serverSupports('CONDSTORE') && !this._box.nomodseq) fetching.push('MODSEQ'); fetching.push('UID'); fetching.push('FLAGS'); fetching.push('INTERNALDATE'); var modifiers; if (options) { modifiers = options.modifiers; if (options.envelope) fetching.push('ENVELOPE'); if (options.struct) fetching.push('BODYSTRUCTURE'); if (options.size) fetching.push('RFC822.SIZE'); if (Array.isArray(options.extensions)) { options.extensions.forEach(function (extension) { fetching.push(extension.toUpperCase()); }); } cmd += fetching.join(' '); if (options.bodies !== undefined) { var bodies = options.bodies, prefix = (options.markSeen ? '' : '.PEEK'); if (!Array.isArray(bodies)) bodies = [bodies]; for (i = 0, len = bodies.length; i < len; ++i) { fetching.push(parseExpr(''+bodies[i])); cmd += ' BODY' + prefix + '[' + bodies[i] + ']'; } } } else cmd += fetching.join(' '); cmd += ')'; var modkeys = (typeof modifiers === 'object' ? Object.keys(modifiers) : []), modstr = ' ('; for (i = 0, len = modkeys.length, key; i < len; ++i) { key = modkeys[i].toUpperCase(); if (key === 'CHANGEDSINCE' && this.serverSupports('CONDSTORE') && !this._box.nomodseq) modstr += key + ' ' + modifiers[modkeys[i]] + ' '; } if (modstr.length > 2) { cmd += modstr.substring(0, modstr.length - 1); cmd += ')'; } this._enqueue(cmd); var req = this._queue[this._queue.length - 1]; req.fetchCache = createFetchTaskTracker(); req.fetching = fetching; return (req.bodyEmitter = new EventEmitter()); }; // Extension methods =========================================================== Connection.prototype.setLabels = function(uids, labels, cb) { this._storeLabels('UID ', uids, labels, '', cb); }; Connection.prototype.addLabels = function(uids, labels, cb) { this._storeLabels('UID ', uids, labels, '+', cb); }; Connection.prototype.delLabels = function(uids, labels, cb) { this._storeLabels('UID ', uids, labels, '-', cb); }; Connection.prototype._storeLabels = function(which, uids, labels, mode, cb) { if (!this.serverSupports('X-GM-EXT-1')) throw new Error('Server must support X-GM-EXT-1 capability'); else if (this._box === undefined) throw new Error('No mailbox is currently selected'); else if (uids === undefined) throw new Error('No messages specified'); if (!Array.isArray(uids)) uids = [uids]; validateUIDList(uids); if (uids.length === 0) { throw new Error('Empty ' + (which === '' ? 'sequence number' : 'uid') + 'list'); } if ((!Array.isArray(labels) && typeof labels !== 'string') || (Array.isArray(labels) && labels.length === 0)) throw new Error('labels argument must be a string or a non-empty Array'); if (!Array.isArray(labels)) labels = [labels]; labels = labels.map(function(v) { return '"' + escape(utf7.encode(''+v)) + '"'; }).join(' '); uids = uids.join(','); this._enqueue(which + 'STORE ' + uids + ' ' + mode + 'X-GM-LABELS.SILENT (' + labels + ')', cb); }; Connection.prototype.sort = function(sorts, criteria, cb) { this._sort('UID ', sorts, criteria, cb); }; Connection.prototype._sort = function(which, sorts, criteria, cb) { if (this._box === undefined) throw new Error('No mailbox is currently selected'); else if (!Array.isArray(sorts) || !sorts.length) throw new Error('Expected array with at least one sort criteria'); else if (!Array.isArray(criteria)) throw new Error('Expected array for search criteria'); else if (!this.serverSupports('SORT')) throw new Error('Sort is not supported on the server'); sorts = sorts.map(function(c) { if (typeof c !== 'string') throw new Error('Unexpected sort criteria data type. ' + 'Expected string. Got: ' + typeof criteria); var modifier = ''; if (c[0] === '-') { modifier = 'REVERSE '; c = c.substring(1); } switch (c.toUpperCase()) { case 'ARRIVAL': case 'CC': case 'DATE': case 'FROM': case 'SIZE': case 'SUBJECT': case 'TO': break; default: throw new Error('Unexpected sort criteria: ' + c); } return modifier + c; }); sorts = sorts.join(' '); var info = { hasUTF8: false /*output*/ }, query = buildSearchQuery(criteria, this._caps, info), charset = 'US-ASCII', lines; if (info.hasUTF8) { charset = 'UTF-8'; lines = query.split(CRLF); query = lines.shift(); } this._enqueue(which + 'SORT (' + sorts + ') ' + charset + query, cb); if (info.hasUTF8) { var req = this._queue[this._queue.length - 1]; req.lines = lines; } }; Connection.prototype.esearch = function(criteria, options, cb) { this._esearch('UID ', criteria, options, cb); }; Connection.prototype._esearch = function(which, criteria, options, cb) { if (this._box === undefined) throw new Error('No mailbox is currently selected'); else if (!Array.isArray(criteria)) throw new Error('Expected array for search options'); var info = { hasUTF8: false /*output*/ }, query = buildSearchQuery(criteria, this._caps, info), charset = '', lines; if (info.hasUTF8) { charset = ' CHARSET UTF-8'; lines = query.split(CRLF); query = lines.shift(); } if (typeof options === 'function') { cb = options; options = ''; } else if (!options) options = ''; if (Array.isArray(options)) options = options.join(' '); this._enqueue(which + 'SEARCH RETURN (' + options + ')' + charset + query, cb); if (info.hasUTF8) { var req = this._queue[this._queue.length - 1]; req.lines = lines; } }; Connection.prototype.setQuota = function(quotaRoot, limits, cb) { if (typeof limits === 'function') { cb = limits; limits = {}; } var triplets = ''; for (var l in limits) { if (triplets) triplets += ' '; triplets += l + ' ' + limits[l]; } quotaRoot = escape(utf7.encode(''+quotaRoot)); this._enqueue('SETQUOTA "' + quotaRoot + '" (' + triplets + ')', function(err, quotalist) { if (err) return cb(err); cb(err, quotalist ? quotalist[0] : limits); } ); }; Connection.prototype.getQuota = function(quotaRoot, cb) { quotaRoot = escape(utf7.encode(''+quotaRoot)); this._enqueue('GETQUOTA "' + quotaRoot + '"', function(err, quotalist) { if (err) return cb(err); cb(err, quotalist[0]); }); }; Connection.prototype.getQuotaRoot = function(boxName, cb) { boxName = escape(utf7.encode(''+boxName)); this._enqueue('GETQUOTAROOT "' + boxName + '"', function(err, quotalist) { if (err) return cb(err); var quotas = {}; if (quotalist) { for (var i = 0, len = quotalist.length; i < len; ++i) quotas[quotalist[i].root] = quotalist[i].resources; } cb(err, quotas); }); }; Connection.prototype.thread = function(algorithm, criteria, cb) { this._thread('UID ', algorithm, criteria, cb); }; Connection.prototype._thread = function(which, algorithm, criteria, cb) { algorithm = algorithm.toUpperCase(); if (!this.serverSupports('THREAD=' + algorithm)) throw new Error('Server does not support that threading algorithm'); var info = { hasUTF8: false /*output*/ }, query = buildSearchQuery(criteria, this._caps, info), charset = 'US-ASCII', lines; if (info.hasUTF8) { charset = 'UTF-8'; lines = query.split(CRLF); query = lines.shift(); } this._enqueue(which + 'THREAD ' + algorithm + ' ' + charset + query, cb); if (info.hasUTF8) { var req = this._queue[this._queue.length - 1]; req.lines = lines; } }; Connection.prototype.addFlagsSince = function(uids, flags, modseq, cb) { this._store('UID ', uids, { mode: '+', flags: flags, modseq: modseq }, cb); }; Connection.prototype.delFlagsSince = function(uids, flags, modseq, cb) { this._store('UID ', uids, { mode: '-', flags: flags, modseq: modseq }, cb); }; Connection.prototype.setFlagsSince = function(uids, flags, modseq, cb) { this._store('UID ', uids, { mode: '', flags: flags, modseq: modseq }, cb); }; Connection.prototype.addKeywordsSince = function(uids, keywords, modseq, cb) { this._store('UID ', uids, { mode: '+', keywords: keywords, modseq: modseq }, cb); }; Connection.prototype.delKeywordsSince = function(uids, keywords, modseq, cb) { this._store('UID ', uids, { mode: '-', keywords: keywords, modseq: modseq }, cb); }; Connection.prototype.setKeywordsSince = function(uids, keywords, modseq, cb) { this._store('UID ', uids, { mode: '', keywords: keywords, modseq: modseq }, cb); }; // END Extension methods ======================================================= // Namespace for seqno-based commands Object.defineProperty(Connection.prototype, 'seq', { get: function() { var self = this; return { delKeywords: function(seqnos, keywords, cb) { self._store('', seqnos, { mode: '-', keywords: keywords }, cb); }, addKeywords: function(seqnos, keywords, cb) { self._store('', seqnos, { mode: '+', keywords: keywords }, cb); }, setKeywords: function(seqnos, keywords, cb) { self._store('', seqnos, { mode: '', keywords: keywords }, cb); }, delFlags: function(seqnos, flags, cb) { self._store('', seqnos, { mode: '-', flags: flags }, cb); }, addFlags: function(seqnos, flags, cb) { self._store('', seqnos, { mode: '+', flags: flags }, cb); }, setFlags: function(seqnos, flags, cb) { self._store('', seqnos, { mode: '', flags: flags }, cb); }, move: function(seqnos, boxTo, cb) { self._move('', seqnos, boxTo, cb); }, copy: function(seqnos, boxTo, cb) { self._copy('', seqnos, boxTo, cb); }, fetch: function(seqnos, options) { return self._fetch('', seqnos, options); }, search: function(options, cb) { self._search('', options, cb); }, // Extensions ============================================================== delLabels: function(seqnos, labels, cb) { self._storeLabels('', seqnos, labels, '-', cb); }, addLabels: function(seqnos, labels, cb) { self._storeLabels('', seqnos, labels, '+', cb); }, setLabels: function(seqnos, labels, cb) { self._storeLabels('', seqnos, labels, '', cb); }, esearch: function(criteria, options, cb) { self._esearch('', criteria, options, cb); }, sort: function(sorts, options, cb) { self._sort('', sorts, options, cb); }, thread: function(algorithm, criteria, cb) { self._thread('', algorithm, criteria, cb); }, delKeywordsSince: function(seqnos, keywords, modseq, cb) { self._store('', seqnos, { mode: '-', keywords: keywords, modseq: modseq }, cb); }, addKeywordsSince: function(seqnos, keywords, modseq, cb) { self._store('', seqnos, { mode: '+', keywords: keywords, modseq: modseq }, cb); }, setKeywordsSince: function(seqnos, keywords, modseq, cb) { self._store('', seqnos, { mode: '', keywords: keywords, modseq: modseq }, cb); }, delFlagsSince: function(seqnos, flags, modseq, cb) { self._store('', seqnos, { mode: '-', flags: flags, modseq: modseq }, cb); }, addFlagsSince: function(seqnos, flags, modseq, cb) { self._store('', seqnos, { mode: '+', flags: flags, modseq: modseq }, cb); }, setFlagsSince: function(seqnos, flags, modseq, cb) { self._store('', seqnos, { mode: '', flags: flags, modseq: modseq }, cb); } }; }}); function parseTypes(typeString) { if (typeString === NoRequest || typeString === AnyRequest) { return [ typeString ]; } else { return typeString .split(/\s*,\s*/) .map((type) => type.toUpperCase()); } } function createCommandHandlers(_rules) { let [ rules ] = validateArguments(arguments, { rules: anyProperty({ key: [ required ], value: [ required, { untagged: anyProperty({ key: [ isString ], value: [ required, isFunction ] }), tagged: [ isFunction ] }] }) }); let untaggedHandlers = new Map(); let taggedHandlers = new Map(); function setUntaggedHandler(requestType, responseType, handler) { if (!untaggedHandlers.has(requestType)) { untaggedHandlers.set(requestType, new Map()); } let handlers = untaggedHandlers.get(requestType); if (!handlers.has(responseType)) { handlers.set(responseType, handler); } else { throw new Error(`Duplicate handler definition for untagged type '${requestType}' -> '${responseType}'`); } } function setTaggedHandler(requestType, handler) { if (!taggedHandlers.has(requestType)) { taggedHandlers.set(requestType, handler); } else { throw new Error(`Duplicate handler definition for tagged type`); } } function getRequestType(request) { return (request != null) ? request.type.toUpperCase() : NoRequest; } function getTaggedHandler(request) { return taggedHandlers.get(getRequestType(request)); } function getUntaggedHandler(request, responseType) { let typeHandlers = untaggedHandlers.get(getRequestType(request)); let anyRequestHandlers = untaggedHandlers.get(AnyRequest); // console.log({ // requestType: getRequestType(request), // responseType: responseType, // anyHandler: anyRequestHandlers.get(responseType) // }); if (typeHandlers != null && typeHandlers.has(responseType)) { return typeHandlers.get(responseType); } else if (anyRequestHandlers != null && anyRequestHandlers.has(responseType)) { return anyRequestHandlers.get(responseType); } } for (let [ typeString, options ] of allEntries(rules)) { for (let type of parseTypes(typeString)) { if (options.untagged != null) { for (let [ responseTypeString, handler ] of allEntries(options.untagged)) { for (let responseType of parseTypes(responseTypeString)) { setUntaggedHandler(type, responseType, handler); } } } if (options.tagged != null) { setTaggedHandler(type, options.tagged); } } } // REFACTOR: Eventually remove `.call(this` hackery // REFACTOR: Remove all the toUpperCase stuff and normalize this in the core instead, so that we can just *assume* it to be upper-case here return { canHandleUntagged: function (request, data) { return (getUntaggedHandler(request, data.type.toUpperCase()) != null); }, canHandleTagged: function (request, _data) { return (getTaggedHandler(request) != null); }, handleUntagged: function (request, data) { let handler = getUntaggedHandler(request, data.type.toUpperCase()); return handler.call(this, request, data); }, handleTagged: function (request, data) { let handler = getTaggedHandler(request); return handler.call(this, request, data); } }; } function allEntries(object) { // The equivalent of Object.entries but also including Symbol keys return Reflect.ownKeys(object).map((key) => { return [ key, object[key] ]; }); } let NoRequest = Symbol("NoRequest"); let AnyRequest = Symbol("AnyRequest"); // FIXME: Strip "UID" prefix from command names before matching, as these only affect the output format, but not the type of response // Exception: EXPUNGE is allowed during UID commands, but not during their non-UID equivalents: https://datatracker.ietf.org/doc/html/rfc3501#page-73 let commandHandlers = createCommandHandlers({ [AnyRequest]: { untagged: { "BYE": function (_request, _) { this._sock.end(); }, "CAPABILITY": function (_request, { payload }) { this._caps = payload.map((v) => v.toUpperCase()); }, "NAMESPACE": function (_request, { payload }) { this.namespaces = payload; }, "PREAUTH": function (_request, _) { this.state = 'authenticated'; }, "EXPUNGE": function (_request, { sequenceNumber }) { if (this._box != null) { if (this._box.messages.total > 0) { this._box.messages.total -= 1; } this.emit('expunge', sequenceNumber); } }, } }, "STATUS": { untagged: { "STATUS": function (request, { payload }) { // REFACTOR: Improve this? let attrs = defaultValue(payload.attrs, {}); let box = { name: payload.name, uidnext: defaultValue(attrs.uidnext, 0), uidvalidity: defaultValue(attrs.uidvalidity, 0), messages: { total: defaultValue(attrs.messages, 0), new: defaultValue(attrs.recent, 0), unseen: defaultValue(attrs.unseen, 0) }, // CONDSTORE highestmodseq: (attrs.highestmodseq != null) ? String(attrs.highestmodseq) : undefined }; request.legacyArgs.push(box); Object.assign(request.responseData, box); } } }, "LIST, XLIST, LSUB": { untagged: { "LIST, XLIST, LSUB": function (request, { payload }) { if (request.delimiter === undefined) { request.delimiter = payload.delimiter; } else { if (request.boxBuilder == null) { request.boxBuilder = createBoxTreeBuilder(); } request.boxBuilder.add(payload); } } }, tagged: function (request, _) { // FIXME: Check request types for correctness let boxTree = (request.boxBuilder != null) ? request.boxBuilder.done() : {}; // No response items were received request.legacyArgs.push(boxTree); request.responseData.boxTree = boxTree; } }, "ID": { // https://datatracker.ietf.org/doc/html/rfc2971 // Used for communicating server/client name, version, etc. untagged: { "ID": function (request, { payload }) { request.responseData.serverVersion = payload; request.legacyArgs.push(payload); } } }, "ESEARCH": { // https://datatracker.ietf.org/doc/html/rfc4731 / https://datatracker.ietf.org/doc/html/rfc7377 untagged: { "ESEARCH": function (request, { payload }) { Object.assign(request.responseData, payload); // Protocol-defined attributes. TODO: Improve the key names for this? Or is there extensibility? request.legacyArgs.push(payload); } } }, "SORT": { // https://datatracker.ietf.org/doc/html/rfc5256 untagged: { "SORT": function (request, { payload }) { request.responseData.UIDs = payload; request.legacyArgs.push(payload); } } }, "THREAD": { // https://datatracker.ietf.org/doc/html/rfc5256 untagged: { "THREAD": function (request, { payload }) { request.responseData.threads = payload; // FIXME: Work out the exact format request.legacyArgs.push(payload); } } }, "SEARCH": { untagged: { "SEARCH": function (request, { payload }) { if (payload.results !== undefined) { // CONDSTORE-modified search results request.legacyArgs.push(payload.results); request.legacyArgs.push(payload.modseq); } else { request.legacyArgs.push(payload); } } } }, "GETQUOTA, GETQUOTAROOT": { // https://datatracker.ietf.org/doc/html/rfc2087 untagged: { "QUOTA": function (request, { payload }) { ensureArray(request.responseData, "quota"); request.responseData.quota.push(payload); ensureArray(request.legacyArgs, 0); request.legacyArgs[0].push(payload); }, "QUOTAROOT": function (_request, _) { throw new Error(`Not implemented`); } } }, "SELECT, EXAMINE": { untagged: { "RECENT": function (_request, { sequenceNumber }) { this._ensureBox(); // FIXME: This conditional is always true? if (this._box) { this._box.messages.new = sequenceNumber; } }, "FLAGS": function (_request, { payload }) { this._ensureBox(); // FIXME: This conditional is always true? if (this._box) { this._box.flags = payload; } }, "EXISTS": function (_request, { sequenceNumber }) { this._ensureBox(); // FIXME: This conditional is always true? if (this._box) { var prev = this._box.messages.total, now = sequenceNumber; this._box.messages.total = now; if (now > prev && this.state === 'authenticated') { this._box.messages.new = now - prev; this.emit('mail', this._box.messages.new); } } }, } }, }); Connection.prototype._ensureBox = function () { if (!this._box) { if (RE_OPENBOX.test(this._curReq.type)) { this._resetCurrentBox(); } else { throw new Error(`Received a box-related response while not processing a box-related request`); } } }; // type: type, // num: num, -- sequence number of the affected nessage, used for FETCH and EXPUNGE only (message-data) and maybe RECENT and EXISTS (mailbox-data)? // textCode: textCode, // text: val // NOTE: responseData is meant to contain machine-readable data, payload is meant to contain human-readable data, but in practice payload is also often machine-parsed Connection.prototype._resUntagged = function({ type, num: sequenceNumber, textCode: responseData, text: payload }) { // console.log("resUntagged", { type, num: sequenceNumber, payload, textCode: responseData }); var i, len, box, destinationKey; let response = { type, sequenceNumber, payload }; if (commandHandlers.canHandleUntagged(this._curReq, response)) { // FIXME: Include other fields commandHandlers.handleUntagged.call(this, this._curReq, response); } else if (type === 'ok') { if (this.state === 'connected' && !this._curReq) { this._login(); } else if (typeof responseData === 'string' && responseData.toUpperCase() === 'ALERT') { this.emit('alert', payload); } else if (this._curReq && responseData && (RE_OPENBOX.test(this._curReq.type))) { // we're opening a mailbox if (!this._box) { this._resetCurrentBox(); } let destinationKey = (responseData.key != null) ? responseData.key.toUpperCase() : responseData; if (destinationKey === 'UIDVALIDITY') { this._box.uidvalidity = responseData.val; } else if (destinationKey === 'UIDNEXT') { this._box.uidnext = responseData.val; } else if (destinationKey === 'HIGHESTMODSEQ') { this._box.highestmodseq = ''+responseData.val; } else if (destinationKey === 'PERMANENTFLAGS') { var idx, permFlags, keywords; this._box.permFlags = permFlags = responseData.val; if ((idx = this._box.permFlags.indexOf('\\*')) > -1) { this._box.newKeywords = true; permFlags.splice(idx, 1); } this._box.keywords = keywords = permFlags.filter((f) => f[0] !== '\\'); for (i = 0, len = keywords.length; i < len; ++i) { permFlags.splice(permFlags.indexOf(keywords[i]), 1); } } else if (destinationKey === 'UIDNOTSTICKY') { this._box.persistentUIDs = false; } else if (destinationKey === 'NOMODSEQ') { this._box.nomodseq = true; } } else if (typeof responseData === 'string' && responseData.toUpperCase() === 'UIDVALIDITY') { this.emit('uidvalidity', payload); } } else if (type === 'bad' || type === 'no') { if (this.state === 'connected' && !this._curReq) { clearTimeout(this._connectionTimeout); clearTimeout(this._authenticationTimeout); var err = new Error('Received negative welcome: ' + payload); err.source = 'protocol'; this.emit('error', err); this._sock.end(); } } else if (type === 'fetch') { if (/^(?:UID )?FETCH/.test(this._curReq.fullcmd)) { // FETCH response sent as result of FETCH request let task = this._curReq.fetchCache.get(sequenceNumber); // FIXME: Refactor, probably make the task itself an event emitter if (task == null) { task = this._curReq.fetchCache.create(sequenceNumber, this._curReq.fetching.slice()); this._curReq.bodyEmitter.emit('message', task.emitter, sequenceNumber); } task.processFetchResponse(payload); } else { // FETCH response sent as result of STORE request or sent unilaterally, // treat them as the same for now for simplicity this.emit('update', sequenceNumber, payload); } } }; Connection.prototype._resTagged = function({ type, tagnum, text: payload, textCode: responseCode }) { // console.log("resTagged", { type, tagnum, payload, textCode: responseCode }); // REFACTOR: textCode: either just the key, or a {key, val} object var request = this._curReq; if (request != null) { var err; this._curReq = undefined; if (type === 'no' || type === 'bad') { // TODO: Can text be an empty string? let errorText = defaultValue(payload, request.oauthError); err = Object.assign(new Error(errorText), { type: type, text: responseCode, source: "protocol" }); } else if (this._box != null) { if (request.type === 'EXAMINE' || request.type === 'SELECT') { this._box.readOnly = ( typeof responseCode === 'string' && responseCode.toUpperCase() === 'READ-ONLY' ); } // According to RFC 3501, UID commands do not give errors for // non-existant user-supplied UIDs, so give the callback empty results // if we unexpectedly received no untagged responses. if (RE_UIDCMD_HASRESULTS.test(request.fullcmd) && request.cbargs.length === 0) { request.cbargs.push([]); } } if (request.bodyEmitter != null) { var bodyEmitter = request.bodyEmitter; if (err) { bodyEmitter.emit('error', err); } process.nextTick(function() { bodyEmitter.emit('end'); }); } else { let extraArguments = asExpression(() => { if (responseCode != null && responseCode.key != null) { return matchValue(responseCode.key.toUpperCase(), { // [uidvalidity, newUID] APPENDUID: [ responseCode.val[1] ], // [uidvalidity, sourceUIDs, destUIDs] COPYUID: [ responseCode.val[2] ], _: [] }); } else { return []; } }); if (responseCode != null && responseCode.key != null) { // FIXME: This eventually should replace the extraArguments array stuff matchValue(responseCode.key.toUpperCase(), { APPENDUID: () => { request.responseData.newUID = responseCode.val[1]; }, COPYUID: () => { // FIXME: Parsing? Looks like it will be multiple items request.responseData.destinationUIDs = responseCode.val[2]; } }); } let response = { type, payload }; if (commandHandlers.canHandleTagged(request, response)) { // FIXME: Add other fields with a sensible name commandHandlers.handleTagged.call(this, request, response); } // console.dir({ done: request.cbargs }, { depth: null, colors: true }); if (request.cb2 != null) { request.cb.apply(this, request.responseData); } else if (request.cb != null) { request.cb.apply(this, [ err, ... request.cbargs, ... extraArguments ]); } } if (this._queue.length === 0 && this._config.keepalive && this.state === 'authenticated' && !this._idle.enabled) { this._idle.enabled = true; this._doKeepaliveTimer(true); } this._processQueue(); } else { // TODO: Configurable logger console.warn(`ignoring response because there is no pending request`); } }; Connection.prototype._resetCurrentBox = function() { this._box = { name: '', flags: [], readOnly: false, uidvalidity: 0, uidnext: 0, permFlags: [], keywords: [], newKeywords: false, persistentUIDs: true, nomodseq: false, messages: { total: 0, new: 0 } }; }; // TODO: Refactor this better Connection.prototype._sendKeepalive = function () { let idleWait = this._config.keepalive.idleInterval || MAX_IDLE_WAIT; let forceNoop = this._config.keepalive.forceNoop || false; if (this._idle.enabled) { // unlike NOOP, IDLE is only a valid command after authenticating if (!this.serverSupports('IDLE') || this.state !== 'authenticated' || forceNoop) { // Ignore return this._enqueue2(command`NOOP`, { insertInFront: true }); this._cancelKeepaliveTimer(); } else { if (this._idle.started === undefined) { this._idle.started = 0; // Ignore return this._enqueue2(command`IDLE`, { insertInFront: true }); } else if (this._idle.started > 0) { var timeDiff = Date.now() - this._idle.started; if (timeDiff >= idleWait) { this._idle.enabled = false; this.debug && this.debug('=> DONE'); // ???? this._sock.write('DONE' + CRLF); return; } } } } }; // TODO: Get rid of Connection.prototype._doKeepaliveTimer = function(immediate) { let interval = this._config.keepalive.interval || KEEPALIVE_INTERVAL; if (immediate) { return this._sendKeepalive(); } else { this._cancelKeepaliveTimer = pInterval(interval, () => { this._sendKeepalive(); }); } }; Connection.prototype._login = function() { var self = this, checkedNS = false; var reentry = function(err) { clearTimeout(self._authenticationTimeout); if (err) { self.emit('error', err); return self._sock.end(); } // 2. Get the list of available namespaces (RFC2342) if (!checkedNS && self.serverSupports('NAMESPACE')) { checkedNS = true; return self._enqueue('NAMESPACE', reentry); } // 3. Get the top-level mailbox hierarchy delimiter used by the server self._enqueue('LIST "" ""', function() { self.state = 'authenticated'; self.emit('ready'); }); }; // 1. Get the supported capabilities self._enqueue('CAPABILITY', function() { // No need to attempt the login sequence if we're on a PREAUTH connection. if (self.state === 'connected') { var err, checkCaps = function(error) { if (error) { error.source = 'authentication'; return reentry(error); } if (self._caps === undefined) { // Fetch server capabilities if they were not automatically // provided after authentication return self._enqueue('CAPABILITY', reentry); } else reentry(); }; if (self.serverSupports('STARTTLS') && (self._config.autotls === 'always' || (self._config.autotls === 'required' && self.serverSupports('LOGINDISABLED')))) { self._starttls(); return; } if (self.serverSupports('LOGINDISABLED')) { err = new Error('Logging in is disabled on this server'); err.source = 'authentication'; return reentry(err); } var cmd; if (self.serverSupports('AUTH=XOAUTH') && self._config.xoauth) { self._caps = undefined; cmd = 'AUTHENTICATE XOAUTH'; // are there any servers that support XOAUTH/XOAUTH2 and not SASL-IR? //if (self.serverSupports('SASL-IR')) cmd += ' ' + escape(self._config.xoauth); self._enqueue(cmd, checkCaps); } else if (self.serverSupports('AUTH=XOAUTH2') && self._config.xoauth2) { self._caps = undefined; cmd = 'AUTHENTICATE XOAUTH2'; //if (self.serverSupports('SASL-IR')) cmd += ' ' + escape(self._config.xoauth2); self._enqueue(cmd, checkCaps); } else if (self._config.user && self._config.password) { self._caps = undefined; self._enqueue('LOGIN "' + escape(self._config.user) + '" "' + escape(self._config.password) + '"', checkCaps); } else { err = new Error('No supported authentication method(s) available. ' + 'Unable to login.'); err.source = 'authentication'; return reentry(err); } } else reentry(); }); }; Connection.prototype._starttls = function() { var self = this; this._enqueue('STARTTLS', function(err) { if (err) { self.emit('error', err); return self._sock.end(); } self._caps = undefined; self._sock.removeAllListeners('error'); var tlsOptions = {}; tlsOptions.host = this._config.host; // Host name may be overridden the tlsOptions for (var k in this._config.tlsOptions) tlsOptions[k] = this._config.tlsOptions[k]; tlsOptions.socket = self._sock; self._sock = tls.connect(tlsOptions, function() { self._login(); }); self._sock.on('error', self._onError); self._sock.on('timeout', self._onSocketTimeout); self._sock.setTimeout(self._config.socketTimeout); self._parser.setStream(self._sock); }); }; Connection.prototype._processQueue = function() { if (this._curReq || !this._queue.length || !this._sock || !this._sock.writable) return; this._curReq = this._queue.shift(); if (this._tagcount === MAX_INT) this._tagcount = 0; var prefix; if (this._curReq.type === 'IDLE' || this._curReq.type === 'NOOP') prefix = this._curReq.type; else prefix = 'A' + (this._tagcount++); var out = prefix + ' ' + this._curReq.fullcmd; this.debug && this.debug('=> ' + inspect(out)); this._sock.write(out + CRLF, 'utf8'); if (this._curReq.literalAppendData) { // LITERAL+: we are appending a mesage, and not waiting for a reply this._sockWriteAppendData(this._curReq.literalAppendData); } }; Connection.prototype._sockWriteAppendData = function(appendData) { var val = appendData; if (Buffer.isBuffer(appendData)) val = val.toString('utf8'); this.debug && this.debug('=> ' + inspect(val)); this._sock.write(val); this._sock.write(CRLF); }; Connection.prototype._enqueue = function(fullcmd, promote, cb, newAPI) { // TODO: Remove variability if (typeof promote === 'function') { cb = promote; promote = false; } var request = { type: fullcmd.match(RE_CMD)[1], fullcmd: fullcmd, cb: (newAPI) ? null : cb, cb2: (newAPI) ? cb : null, cbargs: [], responseData: {} }; // Alias request.legacyArgs = request.cbargs; var self = this; if (promote) { this._queue.unshift(request); } else { this._queue.push(request); } if (!this._curReq && this.state !== 'disconnected' && this.state !== 'upgrading') { // defer until next tick for requests like APPEND and FETCH where access to // the request object is needed immediately after enqueueing process.nextTick(function() { self._processQueue(); }); } else if (this._curReq && this._curReq.type === 'IDLE' && this._sock && this._sock.writable && this._idle.enabled) { this._idle.enabled = false; this._cancelKeepaliveTimer(); if (this._idle.started > 0) { // we've seen the continuation for our IDLE this.debug && this.debug('=> DONE'); this._sock.write('DONE' + CRLF); } } }; Connection.prototype._enqueueAsync = Promise.promisify(Connection.prototype._enqueue); Connection.prototype._enqueue2 = function (command, options = {}) { let insertInFront = defaultValue(options.insertInFront, false); if (command.toCommandString != null) { let string = command.toCommandString(); return this._enqueueAsync(string, insertInFront); } else { // TODO: Use `unreachable` throw unreachable(`Must use a command template string`); } }; Connection.parseHeader = parseHeader; // from Parser.js module.exports = Connection; // utilities ------------------------------------------------------------------- function escape(str) { return str.replace(RE_BACKSLASH, '\\\\').replace(RE_DBLQUOTE, '\\"'); } function validateUIDList(uids, noThrow) { for (var i = 0, len = uids.length, intval; i < len; ++i) { if (typeof uids[i] === 'string') { if (uids[i] === '*' || uids[i] === '*:*') { if (len > 1) uids = ['*']; break; } else if (RE_NUM_RANGE.test(uids[i])) continue; } intval = parseInt(''+uids[i], 10); if (isNaN(intval)) { var err = new Error('UID/seqno must be an integer, "*", or a range: ' + uids[i]); if (noThrow) return err; else throw err; } else if (intval <= 0) { var err = new Error('UID/seqno must be greater than zero'); if (noThrow) return err; else throw err; } else if (typeof uids[i] !== 'number') { uids[i] = intval; } } } function hasNonASCII(str) { for (var i = 0, len = str.length; i < len; ++i) { if (str.charCodeAt(i) > 0x7F) return true; } return false; } function buildString(str) { if (typeof str !== 'string') str = ''+str; if (hasNonASCII(str)) { var buf = Buffer.from(str, 'utf8'); return '{' + buf.length + '}\r\n' + buf.toString('binary'); } else return '"' + escape(str) + '"'; } function buildSearchQuery(options, extensions, info, isOrChild) { var searchargs = '', err, val; for (var i = 0, len = options.length; i < len; ++i) { var criteria = (isOrChild ? options : options[i]), args = null, modifier = (isOrChild ? '' : ' '); if (typeof criteria === 'string') criteria = criteria.toUpperCase(); else if (Array.isArray(criteria)) { if (criteria.length > 1) args = criteria.slice(1); if (criteria.length > 0) criteria = criteria[0].toUpperCase(); } else throw new Error('Unexpected search option data type. ' + 'Expected string or array. Got: ' + typeof criteria); if (criteria === 'OR') { if (args.length !== 2) throw new Error('OR must have exactly two arguments'); if (isOrChild) searchargs += 'OR ('; else searchargs += ' OR ('; searchargs += buildSearchQuery(args[0], extensions, info, true); searchargs += ') ('; searchargs += buildSearchQuery(args[1], extensions, info, true); searchargs += ')'; } else { if (criteria[0] === '!') { modifier += 'NOT '; criteria = criteria.substr(1); } switch(criteria) { // -- Standard criteria -- case 'ALL': case 'ANSWERED': case 'DELETED': case 'DRAFT': case 'FLAGGED': case 'NEW': case 'SEEN': case 'RECENT': case 'OLD': case 'UNANSWERED': case 'UNDELETED': case 'UNDRAFT': case 'UNFLAGGED': case 'UNSEEN': searchargs += modifier + criteria; break; case 'BCC': case 'BODY': case 'CC': case 'FROM': case 'SUBJECT': case 'TEXT': case 'TO': if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); val = buildString(args[0]); if (info && val[0] === '{') info.hasUTF8 = true; searchargs += modifier + criteria + ' ' + val; break; case 'BEFORE': case 'ON': case 'SENTBEFORE': case 'SENTON': case 'SENTSINCE': case 'SINCE': if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); else if (!(args[0] instanceof Date)) { if ((args[0] = new Date(args[0])).toString() === 'Invalid Date') throw new Error('Search option argument must be a Date object' + ' or a parseable date string'); } searchargs += modifier + criteria + ' ' + args[0].getDate() + '-' + MONTHS[args[0].getMonth()] + '-' + args[0].getFullYear(); break; case 'KEYWORD': case 'UNKEYWORD': if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); searchargs += modifier + criteria + ' ' + args[0]; break; case 'LARGER': case 'SMALLER': if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); var num = parseInt(args[0], 10); if (isNaN(num)) throw new Error('Search option argument must be a number'); searchargs += modifier + criteria + ' ' + args[0]; break; case 'HEADER': if (!args || args.length !== 2) throw new Error('Incorrect number of arguments for search option: ' + criteria); val = buildString(args[1]); if (info && val[0] === '{') info.hasUTF8 = true; searchargs += modifier + criteria + ' "' + escape(''+args[0]) + '" ' + val; break; case 'UID': if (!args) throw new Error('Incorrect number of arguments for search option: ' + criteria); validateUIDList(args); if (args.length === 0) throw new Error('Empty uid list'); searchargs += modifier + criteria + ' ' + args.join(','); break; // Extensions ========================================================== case 'X-GM-MSGID': // Gmail unique message ID case 'X-GM-THRID': // Gmail thread ID if (extensions.indexOf('X-GM-EXT-1') === -1) throw new Error('IMAP extension not available for: ' + criteria); if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); else { val = ''+args[0]; if (!(RE_INTEGER.test(args[0]))) throw new Error('Invalid value'); } searchargs += modifier + criteria + ' ' + val; break; case 'X-GM-RAW': // Gmail search syntax if (extensions.indexOf('X-GM-EXT-1') === -1) throw new Error('IMAP extension not available for: ' + criteria); if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); val = buildString(args[0]); if (info && val[0] === '{') info.hasUTF8 = true; searchargs += modifier + criteria + ' ' + val; break; case 'X-GM-LABELS': // Gmail labels if (extensions.indexOf('X-GM-EXT-1') === -1) throw new Error('IMAP extension not available for: ' + criteria); if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); searchargs += modifier + criteria + ' ' + args[0]; break; case 'MODSEQ': if (extensions.indexOf('CONDSTORE') === -1) throw new Error('IMAP extension not available for: ' + criteria); if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); searchargs += modifier + criteria + ' ' + args[0]; break; default: // last hope it's a seqno set // http://tools.ietf.org/html/rfc3501#section-6.4.4 var seqnos = (args ? [criteria].concat(args) : [criteria]); if (!validateUIDList(seqnos, true)) { if (seqnos.length === 0) throw new Error('Empty sequence number list'); searchargs += modifier + seqnos.join(','); } else throw new Error('Unexpected search option: ' + criteria); } } if (isOrChild) break; } return searchargs; } // Pulled from assert.deepEqual: var pSlice = Array.prototype.slice; function _deepEqual(actual, expected) { // 7.1. All identical values are equivalent, as determined by ===. if (actual === expected) { return true; } else if (Buffer.isBuffer(actual) && Buffer.isBuffer(expected)) { if (actual.length !== expected.length) return false; for (var i = 0; i < actual.length; i++) { if (actual[i] !== expected[i]) return false; } return true; // 7.2. If the expected value is a Date object, the actual value is // equivalent if it is also a Date object that refers to the same time. } else if (actual instanceof Date && expected instanceof Date) { return actual.getTime() === expected.getTime(); // 7.3 If the expected value is a RegExp object, the actual value is // equivalent if it is also a RegExp object with the same source and // properties (`global`, `multiline`, `lastIndex`, `ignoreCase`). } else if (actual instanceof RegExp && expected instanceof RegExp) { return actual.source === expected.source && actual.global === expected.global && actual.multiline === expected.multiline && actual.lastIndex === expected.lastIndex && actual.ignoreCase === expected.ignoreCase; // 7.4. Other pairs that do not both pass typeof value == 'object', // equivalence is determined by ==. } else if (typeof actual !== 'object' && typeof expected !== 'object') { return actual == expected; // 7.5 For all other Object pairs, including Array objects, equivalence is // determined by having the same number of owned properties (as verified // with Object.prototype.hasOwnProperty.call), the same set of keys // (although not necessarily the same order), equivalent values for every // corresponding key, and an identical 'prototype' property. Note: this // accounts for both named and indexed properties on Arrays. } else { return objEquiv(actual, expected); } } function isUndefinedOrNull(value) { return value === null || value === undefined; } function isArguments(object) { return Object.prototype.toString.call(object) === '[object Arguments]'; } function objEquiv(a, b) { var ka, kb, key, i; if (isUndefinedOrNull(a) || isUndefinedOrNull(b)) return false; // an identical 'prototype' property. if (a.prototype !== b.prototype) return false; //~~~I've managed to break Object.keys through screwy arguments passing. // Converting to array solves the problem. if (isArguments(a)) { if (!isArguments(b)) { return false; } a = pSlice.call(a); b = pSlice.call(b); return _deepEqual(a, b); } try { ka = Object.keys(a); kb = Object.keys(b); } catch (e) {//happens when one is a string literal and the other isn't return false; } // having the same number of owned properties (keys incorporates // hasOwnProperty) if (ka.length !== kb.length) return false; //the same set of keys (although not necessarily the same order), ka.sort(); kb.sort(); //~~~cheap key test for (i = ka.length - 1; i >= 0; i--) { if (ka[i] != kb[i]) return false; } //equivalent values for every corresponding key, and //~~~possibly expensive deep test for (i = ka.length - 1; i >= 0; i--) { key = ka[i]; if (!_deepEqual(a[key], b[key])) return false; } return true; }