UNPKG

node-imap

Version:

A fork of the famous and outdated IMAP module for node.js that makes communicating with IMAP servers easy

1,532 lines (1,512 loc) 54.2 kB
'use strict' const tls = require('tls'), crypto = require('crypto'), Socket = require('net').Socket, EventEmitter = require('events').EventEmitter, // inherits = require('util').inherits, inspect = require('util').inspect, isDate = require('util').isDate, utf7 = require('utf7').imap, MONTHS = require('util').MONTHS const Parser = require('./Parser').Parser, parseExpr = require('./Parser').parseExpr, parseHeader = require('./Parser').parseHeader const validateUIDList = require('./utils').validateUIDList, _deepEqual = require('./utils')._deepEqual, escape = require('./utils').escape, buildSearchQuery = require('./utils').buildSearchQuery const MAX_INT = Number.MAX_SAFE_INTEGER, KEEPALIVE_INTERVAL = 10000, MAX_IDLE_WAIT = 300000, // 5 minutes 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_ESCAPE = /\\\\/g /** * Connection * @param {*} config */ class Connection extends EventEmitter { constructor(config) { super() 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._tmrConn = undefined this._tmrKeepalive = undefined this._tmrAuth = 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 } connect() { const config = this._config, self = this let socket, parser, tlsOptions socket = config.socket || new Socket() socket.setKeepAlive(true) this._sock = undefined this._tagcount = 0 this._tmrConn = undefined this._tmrKeepalive = undefined this._tmrAuth = 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' if (config.tls) { tlsOptions = {} tlsOptions.host = config.host tlsOptions.servername = config.host // Host name may be overridden the tlsOptions for (let k in config.tlsOptions) tlsOptions[k] = config.tlsOptions[k] tlsOptions.socket = socket } if (config.tls) { this._sock = tls.connect(tlsOptions, onconnect) this._isTsl = true } else { socket.once('connect', onconnect) this._sock = socket } function onconnect() { clearTimeout(self._tmrConn) self.state = 'connected' self.debug && self.debug('[connection] Connected to host') self._tmrAuth = setTimeout(function () { const err = new Error('Timed out while authenticating with server') err.source = 'timeout-auth' self.emit('error', err) socket.destroy() }, config.authTimeout) } let additionalClose = !config.socket this._onError = function (err) { clearTimeout(self._tmrConn) clearTimeout(self._tmrAuth) self.debug && self.debug('[connection] Error: ' + err) err.source = 'socket' if (additionalClose && self._isTsl) socket.destroy() self.emit('error', err) } this._sock.on('error', this._onError) this._onSocketTimeout = function () { clearTimeout(self._tmrConn) clearTimeout(self._tmrAuth) clearTimeout(self._tmrKeepalive) self.state = 'disconnected' self.debug && self.debug('[connection] Socket timeout') const 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) { clearTimeout(self._tmrConn) clearTimeout(self._tmrAuth) clearTimeout(self._tmrKeepalive) self.state = 'disconnected' self.debug && self.debug('[connection] Closed') self.emit('close', had_err) }) socket.once('end', function () { clearTimeout(self._tmrConn) clearTimeout(self._tmrAuth) clearTimeout(self._tmrKeepalive) self.state = 'disconnected' 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) { let msg = self._curReq.fetchCache[info.seqno] let toget if (msg === undefined) { msg = self._curReq.fetchCache[info.seqno] = { msgEmitter: new EventEmitter(), toget: self._curReq.fetching.slice(0), attrs: {}, ended: false } self._curReq.bodyEmitter.emit('message', msg.msgEmitter, info.seqno) } toget = msg.toget // 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 const thisbody = parseExpr(info.which.toUpperCase()) for (let i = 0, len = toget.length; i < len; ++i) { if (_deepEqual(thisbody, toget[i])) { toget.splice(i, 1) msg.msgEmitter.emit('body', stream, info) return } } stream.resume() // a body we didn't ask for? }) parser.on('continue', function (info) { const 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') //new Buffer(info.text, 'base64').toString('utf8'); self.debug && self.debug('=> ' + inspect(CRLF)) self._sock.write(CRLF) } else if (/^AUTHENTICATE CRAM-MD5/.test(self._curReq.fullcmd)) { self._authCRAMMD5(info.text) } else if (type === 'APPEND') { self._sockWriteAppendData(self._curReq.appendData) } else if (self._curReq.lines && self._curReq.lines.length) { const line = self._curReq.lines.shift() + '\r\n' self.debug && self.debug('=> ' + inspect(line)) self._sock.write(line, 'binary') } }) parser.on('other', function (line) { let m if (m = RE_IDLENOOPRES.exec(line)) { // no longer idling self._idle.enabled = false self._idle.started = undefined clearTimeout(self._tmrKeepalive) 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._tmrConn = setTimeout(function () { const 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 }) } serverSupports(cap) { return (this._caps && this._caps.indexOf(cap) > -1) } destroy() { this._queue = [] this._curReq = undefined this._sock && this._sock.end() } end() { const self = this this._enqueue('LOGOUT', function () { self._queue = [] self._curReq = undefined self._sock.end() }) } append(data, options, cb) { const 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 } let 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 (let 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 += ('0' + options.date.getDate()).slice(-2) 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 } getSpecialUseBoxes(cb) { this._enqueue('XLIST "" "*"', cb) } getBoxes(namespace, cb) { if (typeof namespace === 'function') { cb = namespace namespace = '' } namespace = escape(utf7.encode('' + namespace)) this._enqueue('LIST "' + namespace + '" "*"', cb) } id(identification, cb) { if (!this.serverSupports('ID')) throw new Error('Server does not support ID') let 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') const kv = [] for (let 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) } openBox(name, readOnly, cb) { if (this.state !== 'authenticated') throw new Error('Not authenticated') if (typeof readOnly === 'function') { cb = readOnly readOnly = false } name = '' + name const encname = escape(utf7.encode(name)) let 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) } }) } closeBox(shouldExpunge, cb) { if (this._box === undefined) throw new Error('No mailbox is currently selected') const 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 const badbox = 'NODEJSIMAPCLOSINGBOX' + Date.now() this._enqueue('SELECT "' + badbox + '"', function (err) { self._box = undefined cb() }) } } } addBox(name, cb) { this._enqueue('CREATE "' + escape(utf7.encode('' + name)) + '"', cb) } delBox(name, cb) { this._enqueue('DELETE "' + escape(utf7.encode('' + name)) + '"', cb) } renameBox(oldname, newname, cb) { const 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() }) } subscribeBox(name, cb) { this._enqueue('SUBSCRIBE "' + escape(utf7.encode('' + name)) + '"', cb) } unsubscribeBox(name, cb) { this._enqueue('UNSUBSCRIBE "' + escape(utf7.encode('' + name)) + '"', cb) } getSubscribedBoxes(namespace, cb) { if (typeof namespace === 'function') { cb = namespace namespace = '' } namespace = escape(utf7.encode('' + namespace)) this._enqueue('LSUB "' + namespace + '" "*"', cb) } status(boxName, cb) { if (this._box && this._box.name === boxName) throw new Error('Cannot call status on currently selected mailbox') boxName = escape(utf7.encode('' + boxName)) let info = ['MESSAGES', 'RECENT', 'UNSEEN', 'UIDVALIDITY', 'UIDNEXT'] if (this.serverSupports('CONDSTORE')) info.push('HIGHESTMODSEQ') info = info.join(' ') this._enqueue('STATUS "' + boxName + '" (' + info + ')', cb) } expunge(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) } search(criteria, cb) { this._search('UID ', criteria, cb) } _search(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') let cmd = which + 'SEARCH', info = { hasUTF8: false /*output*/ }, query = buildSearchQuery(criteria, this._caps, info) let lines if (info.hasUTF8) { cmd += ' CHARSET UTF-8' lines = query.split(CRLF) query = lines.shift() } cmd += query this._enqueue(cmd, cb) if (info.hasUTF8) { const req = this._queue[this._queue.length - 1] req.lines = lines } } addFlags(uids, flags, cb) { this._store('UID ', uids, { mode: '+', flags: flags }, cb) } delFlags(uids, flags, cb) { this._store('UID ', uids, { mode: '-', flags: flags }, cb) } setFlags(uids, flags, cb) { this._store('UID ', uids, { mode: '', flags: flags }, cb) } addKeywords(uids, keywords, cb) { this._store('UID ', uids, { mode: '+', keywords: keywords }, cb) } delKeywords(uids, keywords, cb) { this._store('UID ', uids, { mode: '-', keywords: keywords }, cb) } setKeywords(uids, keywords, cb) { this._store('UID ', uids, { mode: '', keywords: keywords }, cb) } _store(which, uids, cfg, cb) { const mode = cfg.mode, isFlags = (cfg.flags !== undefined) let 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 (let 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(',') let modifiers = '' if (cfg.modseq !== undefined && !this._box.nomodseq) modifiers += 'UNCHANGEDSINCE ' + cfg.modseq + ' ' this._enqueue(which + 'STORE ' + uids + ' ' + modifiers + mode + 'FLAGS.SILENT (' + items + ')', cb) } copy(uids, boxTo, cb) { this._copy('UID ', uids, boxTo, cb) } _copy(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) } move(uids, boxTo, cb) { this._move('UID ', uids, boxTo, cb) } _move(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 { let deletedUIDs let task = 0 const 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) { const 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) } }) } } fetch(uids, options) { return this._fetch('UID ', uids, options) } _fetch(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(',') let cmd = which + 'FETCH ' + uids + ' (' const fetching = [] let 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') let 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) { let 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 += ')' const modkeys = (typeof modifiers === 'object' ? Object.keys(modifiers) : []) let 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) const req = this._queue[this._queue.length - 1] req.fetchCache = {} req.fetching = fetching return (req.bodyEmitter = new EventEmitter()) } // Extension methods =========================================================== setLabels(uids, labels, cb) { this._storeLabels('UID ', uids, labels, '', cb) } addLabels(uids, labels, cb) { this._storeLabels('UID ', uids, labels, '+', cb) } delLabels(uids, labels, cb) { this._storeLabels('UID ', uids, labels, '-', cb) } _storeLabels(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) } sort(sorts, criteria, cb) { this._sort('UID ', sorts, criteria, cb) } _sort(which, sorts, criteria, cb) { let displaySupported = false 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') if (this.serverSupports('SORT=DISPLAY')) { displaySupported = true } sorts = sorts.map(function (c) { if (typeof c !== 'string') throw new Error('Unexpected sort criteria data type. ' + 'Expected string. Got: ' + typeof criteria) let 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 case 'DISPLAYFROM': case 'DISPLAYTO': if (!displaySupported) { throw new Error('Unexpected sort criteria: ' + c) } break default: throw new Error('Unexpected sort criteria: ' + c) } return modifier + c }) sorts = sorts.join(' ') const info = { hasUTF8: false /*output*/ } let query = buildSearchQuery(criteria, this._caps, info) let charset = 'US-ASCII' let 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) { const req = this._queue[this._queue.length - 1] req.lines = lines } } esearch(criteria, options, cb) { this._esearch('UID ', criteria, options, cb) } _esearch(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') const info = { hasUTF8: false /*output*/ } let query = buildSearchQuery(criteria, this._caps, info) let charset = '' let 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) { const req = this._queue[this._queue.length - 1] req.lines = lines } } setQuota(quotaRoot, limits, cb) { if (typeof limits === 'function') { cb = limits limits = {} } let triplets = '' for (let 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) }) } getQuota(quotaRoot, cb) { quotaRoot = escape(utf7.encode('' + quotaRoot)) this._enqueue('GETQUOTA "' + quotaRoot + '"', function (err, quotalist) { if (err) return cb(err) cb(err, quotalist[0]) }) } getQuotaRoot(boxName, cb) { boxName = escape(utf7.encode('' + boxName)) this._enqueue('GETQUOTAROOT "' + boxName + '"', function (err, quotalist) { if (err) return cb(err) const quotas = {} if (quotalist) { for (let i = 0, len = quotalist.length; i < len; ++i) quotas[quotalist[i].root] = quotalist[i].resources } cb(err, quotas) }) } thread(algorithm, criteria, cb) { this._thread('UID ', algorithm, criteria, cb) } _thread(which, algorithm, criteria, cb) { algorithm = algorithm.toUpperCase() if (!this.serverSupports('THREAD=' + algorithm)) throw new Error('Server does not support that threading algorithm') const info = { hasUTF8: false /*output*/ } let query = buildSearchQuery(criteria, this._caps, info) let charset = 'US-ASCII' let 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) { const req = this._queue[this._queue.length - 1] req.lines = lines } } addFlagsSince(uids, flags, modseq, cb) { this._store('UID ', uids, { mode: '+', flags: flags, modseq: modseq }, cb) } delFlagsSince(uids, flags, modseq, cb) { this._store('UID ', uids, { mode: '-', flags: flags, modseq: modseq }, cb) } setFlagsSince(uids, flags, modseq, cb) { this._store('UID ', uids, { mode: '', flags: flags, modseq: modseq }, cb) } addKeywordsSince(uids, keywords, modseq, cb) { this._store('UID ', uids, { mode: '+', keywords: keywords, modseq: modseq }, cb) } delKeywordsSince(uids, keywords, modseq, cb) { this._store('UID ', uids, { mode: '-', keywords: keywords, modseq: modseq }, cb) } setKeywordsSince(uids, keywords, modseq, cb) { this._store('UID ', uids, { mode: '', keywords: keywords, modseq: modseq }, cb) } _resUntagged(info) { const type = info.type let i, len, box, attrs, key if (type === 'bye') this._sock.end() else if (type === 'namespace') this.namespaces = info.text else if (type === 'id') this._curReq.cbargs.push(info.text) else if (type === 'capability') this._caps = info.text.map(function (v) { return v.toUpperCase() }) else if (type === 'preauth') this.state = 'authenticated' else if (type === 'sort' || type === 'thread' || type === 'esearch') this._curReq.cbargs.push(info.text) else if (type === 'search') { if (info.text.results !== undefined) { // CONDSTORE-modified search results this._curReq.cbargs.push(info.text.results) this._curReq.cbargs.push(info.text.modseq) } else this._curReq.cbargs.push(info.text) } else if (type === 'quota') { const cbargs = this._curReq.cbargs if (!cbargs.length) cbargs.push([]) cbargs[0].push(info.text) } else if (type === 'recent') { if (!this._box && RE_OPENBOX.test(this._curReq.type)) this._createCurrentBox() if (this._box) this._box.messages.new = info.num } else if (type === 'flags') { if (!this._box && RE_OPENBOX.test(this._curReq.type)) this._createCurrentBox() if (this._box) this._box.flags = info.text } else if (type === 'bad' || type === 'no') { if (info.textCode && info.textCode.key && info.textCode.key === 'WEBALERT' ) { this.emit('webalert', { url: info.textCode.val, message: info.text }) } if (this.state === 'connected' && !this._curReq) { clearTimeout(this._tmrConn) clearTimeout(this._tmrAuth) const err = new Error('Received negative welcome: ' + info.text) err.source = 'protocol' this.emit('error', err) this._sock.end() } } else if (type === 'exists') { if (!this._box && RE_OPENBOX.test(this._curReq.type)) this._createCurrentBox() if (this._box) { let prev = this._box.messages.total, now = info.num this._box.messages.total = now if (this._box.name === '') prev = now if (now > prev && this.state === 'authenticated') { this._box.messages.new = now - prev this.emit('mail', this._box.messages.new) } } } else if (type === 'expunge') { if (this._box) { if (this._box.messages.total > 0) --this._box.messages.total this.emit('expunge', info.num) } } else if (type === 'ok') { if (this.state === 'connected' && !this._curReq) this._login() else if (typeof info.textCode === 'string' && info.textCode.toUpperCase() === 'ALERT') this.emit('alert', info.text) else if (this._curReq && info.textCode && (RE_OPENBOX.test(this._curReq.type))) { // we're opening a mailbox if (!this._box) this._createCurrentBox() if (info.textCode.key) key = info.textCode.key.toUpperCase() else key = info.textCode if (key === 'UIDVALIDITY') this._box.uidvalidity = info.textCode.val else if (key === 'UIDNEXT') this._box.uidnext = info.textCode.val else if (key === 'HIGHESTMODSEQ') this._box.highestmodseq = '' + info.textCode.val else if (key === 'PERMANENTFLAGS') { let idx, permFlags, keywords this._box.permFlags = permFlags = info.textCode.val if ((idx = this._box.permFlags.indexOf('\\*')) > -1) { this._box.newKeywords = true permFlags.splice(idx, 1) } this._box.keywords = keywords = permFlags.filter(function (f) { return (f[0] !== '\\') }) for (i = 0, len = keywords.length; i < len; ++i) permFlags.splice(permFlags.indexOf(keywords[i]), 1) } else if (key === 'UIDNOTSTICKY') this._box.persistentUIDs = false else if (key === 'NOMODSEQ') this._box.nomodseq = true } else if (typeof info.textCode === 'string' && info.textCode.toUpperCase() === 'UIDVALIDITY') this.emit('uidvalidity', info.text) } else if (type === 'list' || type === 'lsub' || type === 'xlist') { if (this.delimiter === undefined) this.delimiter = info.text.delimiter else { if (this._curReq.cbargs.length === 0) this._curReq.cbargs.push({}) box = { attribs: info.text.flags, delimiter: info.text.delimiter, children: null, parent: null } for (i = 0, len = SPECIAL_USE_ATTRIBUTES.length; i < len; ++i) if (box.attribs.indexOf(SPECIAL_USE_ATTRIBUTES[i]) > -1) box.special_use_attrib = SPECIAL_USE_ATTRIBUTES[i] let name = info.text.name let curChildren = this._curReq.cbargs[0] if (box.delimiter) { const path = name.split(box.delimiter) let parent = null name = path.pop() for (i = 0, len = path.length; i < len; ++i) { if (!curChildren[path[i]]) curChildren[path[i]] = {} if (!curChildren[path[i]].children) curChildren[path[i]].children = {} parent = curChildren[path[i]] curChildren = curChildren[path[i]].children } box.parent = parent } if (curChildren[name]) box.children = curChildren[name].children curChildren[name] = box } } else if (type === 'status') { box = { name: info.text.name, uidnext: 0, uidvalidity: 0, messages: { total: 0, new: 0, unseen: 0 } } attrs = info.text.attrs if (attrs) { if (attrs.recent !== undefined) box.messages.new = attrs.recent if (attrs.unseen !== undefined) box.messages.unseen = attrs.unseen if (attrs.messages !== undefined) box.messages.total = attrs.messages if (attrs.uidnext !== undefined) box.uidnext = attrs.uidnext if (attrs.uidvalidity !== undefined) box.uidvalidity = attrs.uidvalidity if (attrs.highestmodseq !== undefined) // CONDSTORE box.highestmodseq = '' + attrs.highestmodseq } this._curReq.cbargs.push(box) } else if (type === 'fetch') { if (/^(?:UID )?FETCH/.test(this._curReq.fullcmd)) { // FETCH response sent as result of FETCH request const msg = this._curReq.fetchCache[info.num], keys = Object.keys(info.text), keyslen = keys.length let toget, msgEmitter, j if (msg === undefined) { // simple case -- no bodies were streamed toget = this._curReq.fetching.slice(0) if (toget.length === 0) return msgEmitter = new EventEmitter() attrs = {} this._curReq.bodyEmitter.emit('message', msgEmitter, info.num) } else { toget = msg.toget msgEmitter = msg.msgEmitter attrs = msg.attrs } i = toget.length if (i === 0) { if (msg && !msg.ended) { msg.ended = true process.nextTick(function () { msgEmitter.emit('end') }) } return } if (keyslen > 0) { while (--i >= 0) { j = keyslen while (--j >= 0) { if (keys[j].toUpperCase() === toget[i]) { if (!RE_BODYPART.test(toget[i])) { if (toget[i] === 'X-GM-LABELS') { const labels = info.text[keys[j]] for (let k = 0, lenk = labels.length; k < lenk; ++k) labels[k] = utf7.decode(('' + labels[k]).replace(RE_ESCAPE, '\\')) } key = FETCH_ATTR_MAP[toget[i]] if (!key) key = toget[i].toLowerCase() attrs[key] = info.text[keys[j]] } toget.splice(i, 1) break } } } } if (toget.length === 0) { if (msg) msg.ended = true process.nextTick(function () { msgEmitter.emit('attributes', attrs) msgEmitter.emit('end') }) } else if (msg === undefined) { this._curReq.fetchCache[info.num] = { msgEmitter: msgEmitter, toget: toget, attrs: attrs, ended: false } } } else { // FETCH response sent as result of STORE request or sent unilaterally, // treat them as the same for now for simplicity this.emit('update', info.num, info.text) } } } _resTagged(info) { const req = this._curReq let err if (!req) return this._curReq = undefined if (info.type === 'no' || info.type === 'bad') { let errtext if (info.text) errtext = info.text else errtext = req.oauthError err = new Error(errtext) err.type = info.type err.textCode = info.textCode err.source = 'protocol' } else if (this._box) { if (req.type === 'EXAMINE' || req.type === 'SELECT') { this._box.readOnly = (typeof info.textCode === 'string' && info.textCode.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(req.fullcmd) && req.cbargs.length === 0) req.cbargs.push([]) } if (req.bodyEmitter) { const bodyEmitter = req.bodyEmitter if (err) { bodyEmitter.emit('error', err) } process.nextTick(function () { bodyEmitter.emit('end') }) } else { req.cbargs.unshift(err) if (info.textCode && info.textCode.key) { const key = info.textCode.key.toUpperCase() if (key === 'APPENDUID') // [uidvalidity, newUID] req.cbargs.push(info.textCode.val[1]) else if (key === 'COPYUID') // [uidvalidity, sourceUIDs, destUIDs] req.cbargs.push(info.textCode.val[2]) } req.cb && req.cb.apply(this, req.cbargs) } if (this._queue.length === 0 && this._config.keepalive && this.state === 'authenticated' && !this._idle.enabled) { this._idle.enabled = true this._doKeepaliveTimer(true) } this._processQueue() } _createCurrentBox() { this._box = { name: '', flags: [], readOnly: false, uidvalidity: 0, uidnext: 0, permFlags: [], keywords: [], newKeywords: false, persistentUIDs: true, nomodseq: false, messages: { total: 0, new: 0 } } } _doKeepaliveTimer(immediate) { const self = this, interval = this._config.keepalive.interval || KEEPALIVE_INTERVAL, idleWait = this._config.keepalive.idleInterval || MAX_IDLE_WAIT, forceNoop = this._config.keepalive.forceNoop || false, timerfn = function () { if (self._idle.enabled) { // unlike NOOP, IDLE is only a valid command after authenticating if (!self.serverSupports('IDLE') || self.state !== 'authenticated' || forceNoop) self._enqueue('NOOP', true) else { if (self._idle.started === undefined) { self._idle.started = 0 self._enqueue('IDLE', true) } else if (self._idle.started > 0) { const timeDiff = Date.now() - self._idle.started if (timeDiff >= idleWait) { self._idle.enabled = false self.debug && self.debug('=> DONE') self._sock.write('DONE' + CRLF) return } } self._tmrKeepalive = setTimeout(timerfn, interval) } } } if (immediate) timerfn() else this._tmrKeepalive = setTimeout(timerfn, interval) } _login() { const self = this let checkedNS = false const reentry = function (err) { clearTimeout(self._tmrAuth) 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') { let err const 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) } let 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) { if (self.serverSupports('AUTH=CRAM-MD5')) { cmd = 'AUTHENTICATE CRAM-MD5' } else { cmd = 'LOGIN "' + escape(self._config.user) + '" "' + escape(self._config.password) + '"' } self._caps = undefined self._enqueue(cmd, checkCaps) } else { err = new Error('No supported authentication method(s) available. ' + 'Unable to login.') err.source = 'authentication' return reentry(err) } } else reentry() }) } _authCRAMMD5(secret) { let decodedSecret, hmac, response decodedSecret = Buffer.from(secret, 'base64').toString('utf8') hmac = crypto.createHmac('md5', this._config.password) .update(decodedSecret) .digest('hex') response = new Buffer(this._config.user + ' ' + hmac).toString('base64') this.debug && this.debug('=> ' + response) this._sock.write(response + CRLF, 'utf8') } _starttls() { const self = this this._enqueue('STARTTLS', function (err) { if (err) { self.emit('error', err) return self._sock.end() } self._isTsl = true self._caps = undefined self._sock.removeAllListeners('error') const tlsOptions = {} tlsOptions.host = this._config.host // Host name may be overridden the tlsOptions for (let 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) }) } _processQueue() { 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 let prefix if (this._curReq.type === 'IDLE' || this._curReq.type === 'NOOP') prefix = this._curReq.type else prefix = 'A' + (this._tagcount++) const 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) } } _sockWriteAppendData(appendData) { let val = appendData if (Buffer.isBuffer(appendData)) val = val.toString('utf8') this.debug && this.debug('=> ' + inspect(val)) this._sock.write(val) this._sock.write(CRLF) } _enqueue(fullcmd, promote, cb) { if (typeof promote === 'function') { cb = promote promote = false } const info = { type: fullcmd.match(RE_CMD)[1], fullcmd: fullcmd, cb: cb, cbargs: [] }, self = this if (promote) this._queue.unshift(info)