UNPKG

imapflow

Version:

IMAP Client for Node

1,417 lines (1,204 loc) 134 kB
'use strict'; /** * @module imapflow */ const tls = require('tls'); const net = require('net'); const crypto = require('crypto'); const { EventEmitter } = require('events'); const logger = require('./logger'); const libmime = require('libmime'); const zlib = require('zlib'); const { Headers } = require('mailsplit'); const { LimitedPassthrough } = require('./limited-passthrough'); const { ImapStream } = require('./handler/imap-stream'); const { parser, compiler } = require('./handler/imap-handler'); const packageInfo = require('../package.json'); const libqp = require('libqp'); const libbase64 = require('libbase64'); const FlowedDecoder = require('mailsplit/lib/flowed-decoder'); const { PassThrough } = require('stream'); const { proxyConnection } = require('./proxy-connection'); const { comparePaths, updateCapabilities, getFolderTree, formatMessageResponse, getDecoder, packMessageRange, normalizePath, expandRange, AuthenticationFailure, getColorFlags } = require('./tools'); const imapCommands = require('./imap-commands.js'); const CONNECT_TIMEOUT = 90 * 1000; const GREETING_TIMEOUT = 16 * 1000; const UPGRADE_TIMEOUT = 10 * 1000; const SOCKET_TIMEOUT = 5 * 60 * 1000; const states = { NOT_AUTHENTICATED: 0x01, AUTHENTICATED: 0x02, SELECTED: 0x03, LOGOUT: 0x04 }; /** * @typedef {Object} MailboxObject * @global * @property {String} path mailbox path * @property {String} delimiter mailbox path delimiter, usually "." or "/" * @property {Set<string>} flags list of flags for this mailbox * @property {String} [specialUse] one of special-use flags (if applicable): "\All", "\Archive", "\Drafts", "\Flagged", "\Junk", "\Sent", "\Trash". Additionally INBOX has non-standard "\Inbox" flag set * @property {Boolean} listed `true` if mailbox was found from the output of LIST command * @property {Boolean} subscribed `true` if mailbox was found from the output of LSUB command * @property {Set<string>} permanentFlags A Set of flags available to use in this mailbox. If it is not set or includes special flag "\\\*" then any flag can be used. * @property {String} [mailboxId] unique mailbox ID if server has `OBJECTID` extension enabled * @property {BigInt} [highestModseq] latest known modseq value if server has CONDSTORE or XYMHIGHESTMODSEQ enabled * @property {String} [noModseq] if true then the server doesn't support the persistent storage of mod-sequences for the mailbox * @property {BigInt} uidValidity Mailbox `UIDVALIDITY` value * @property {Number} uidNext Next predicted UID * @property {Number} exists Messages in this folder */ /** * @typedef {Object} MailboxLockObject * @global * @property {String} path mailbox path * @property {Function} release Release current lock * @example * let lock = await client.getMailboxLock('INBOX'); * try { * // do something in the mailbox * } finally { * // use finally{} to make sure lock is released even if exception occurs * lock.release(); * } */ /** * Client and server identification object, where key is one of RFC2971 defined [data fields](https://tools.ietf.org/html/rfc2971#section-3.3) (but not limited to). * @typedef {Object} IdInfoObject * @global * @property {String} [name] Name of the program * @property {String} [version] Version number of the program * @property {String} [os] Name of the operating system * @property {String} [vendor] Vendor of the client/server * @property {String} ['support-url'] URL to contact for support * @property {Date} [date] Date program was released */ /** * IMAP client class for accessing IMAP mailboxes * * @class * @extends EventEmitter */ class ImapFlow extends EventEmitter { /** * Current module version as a static class property * @property {String} version Module version * @static */ static version = packageInfo.version; /** * IMAP connection options * * @property {String} host * Hostname of the IMAP server. * * @property {Number} port * Port number for the IMAP server. * * @property {Boolean} [secure=false] * If `true`, establishes the connection directly over TLS (commonly on port 993). * If `false`, a plain (unencrypted) connection is used first and, if possible, the connection is upgraded to STARTTLS. * * @property {Boolean} [doSTARTTLS=undefined] * Determines whether to upgrade the connection to TLS via STARTTLS: * - **true**: Start unencrypted and upgrade to TLS using STARTTLS before authentication. * The connection fails if the server does not support STARTTLS or the upgrade fails. * Note that `secure=true` combined with `doSTARTTLS=true` is invalid. * - **false**: Never use STARTTLS, even if the server advertises support. * This is useful if the server has a broken TLS setup. * Combined with `secure=false`, this results in a fully unencrypted connection. * Make sure you warn users about the security risks. * - **undefined** (default): If `secure=false` (default), attempt to upgrade to TLS via STARTTLS before authentication if the server supports it. If not supported, continue unencrypted. This may expose the connection to a downgrade attack. * * @property {String} [servername] * Server name for SNI or when using an IP address as `host`. * * @property {Boolean} [disableCompression=false] * If `true`, the client does not attempt to use the COMPRESS=DEFLATE extension. * * @property {Object} auth * Authentication options. Authentication occurs automatically during {@link connect}. * * @property {String} auth.user * Username for authentication. * * @property {String} [auth.pass] * Password for regular authentication. * * @property {String} [auth.accessToken] * OAuth2 access token, if using OAuth2 authentication. * * @property {String} [auth.loginMethod] * Optional login method for password-based authentication (e.g., "LOGIN", "AUTH=LOGIN", or "AUTH=PLAIN"). * If not set, ImapFlow chooses based on available mechanisms. * * @property {IdInfoObject} [clientInfo] * Client identification info sent to the server (via the ID command). * * @property {Boolean} [disableAutoIdle=false] * If `true`, do not start IDLE automatically. Useful when only specific operations are needed. * * @property {Object} [tls] * Additional TLS options. For details, see [Node.js TLS connect](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback). * * @property {Boolean} [tls.rejectUnauthorized=true] * If `false`, allows self-signed or expired certificates. * * @property {String} [tls.minVersion='TLSv1.2'] * Minimum accepted TLS version (e.g., `'TLSv1.2'`). * * @property {Number} [tls.minDHSize=1024] * Minimum size (in bits) of the DH parameter for TLS connections. * * @property {Object|Boolean} [logger] * Custom logger instance with `debug(obj)`, `info(obj)`, `warn(obj)`, and `error(obj)` methods. * If `false`, logging is disabled. If not provided, ImapFlow logs to console in [pino format](https://getpino.io/). * * @property {Boolean} [logRaw=false] * If `true`, logs all raw data (read and written) in base64 encoding. You can pipe such logs to [eerawlog](https://github.com/postalsys/eerawlog) command for readable output. * * @property {Boolean} [emitLogs=false] * If `true`, emits `'log'` events with the same data passed to the logger. * * @property {Boolean} [verifyOnly=false] * If `true`, disconnects after successful authentication without performing other actions. * * @property {String} [proxy] * Proxy URL. Supports HTTP CONNECT (`http://`, `https://`) and SOCKS (`socks://`, `socks4://`, `socks5://`). * * @property {Boolean} [qresync=false] * If `true`, enables QRESYNC support so that EXPUNGE notifications include `uid` instead of `seq`. * * @property {Number} [maxIdleTime] * If set, breaks and restarts IDLE every `maxIdleTime` milliseconds. * * @property {String} [missingIdleCommand="NOOP"] * Command to use if the server does not support IDLE. * * @property {Boolean} [disableBinary=false] * If `true`, ignores the BINARY extension for FETCH and APPEND operations. * * @property {Boolean} [disableAutoEnable=false] * If `true`, do not automatically enable supported IMAP extensions. * * @property {Number} [connectionTimeout=90000] * Maximum time (in milliseconds) to wait for the connection to establish. Defaults to 90 seconds. * * @property {Number} [greetingTimeout=16000] * Maximum time (in milliseconds) to wait for the server greeting after a connection is established. Defaults to 16 seconds. * * @property {Number} [socketTimeout=300000] * Maximum period of inactivity (in milliseconds) before terminating the connection. Defaults to 5 minutes. */ constructor(options) { super({ captureRejections: true }); this.options = options || {}; /** * Instance ID for logs * @type {String} */ this.id = this.options.id || this.getRandomId(); this.clientInfo = Object.assign( { name: packageInfo.name, version: packageInfo.version, vendor: 'Postal Systems', 'support-url': 'https://github.com/postalsys/imapflow/issues' }, this.options.clientInfo || {} ); // remove diacritics for (let key of Object.keys(this.clientInfo)) { if (typeof this.clientInfo[key] === 'string') { this.clientInfo[key] = this.clientInfo[key].normalize('NFD').replace(/\p{Diacritic}/gu, ''); } } /** * Server identification info. Available after successful `connect()`. * If server does not provide identification info then this value is `null`. * @example * await client.connect(); * console.log(client.serverInfo.vendor); * @type {IdInfoObject|null} */ this.serverInfo = null; //updated by ID this.log = this.getLogger(); /** * Is the connection currently encrypted or not * @type {Boolean} */ this.secureConnection = !!this.options.secure; this.port = Number(this.options.port) || (this.secureConnection ? 993 : 110); this.host = this.options.host || 'localhost'; this.servername = this.options.servername ? this.options.servername : !net.isIP(this.host) ? this.host : false; if (typeof this.options.secure === 'undefined' && this.port === 993) { // if secure option is not set but port is 465, then default to secure this.secureConnection = true; } this.logRaw = this.options.logRaw; this.streamer = new ImapStream({ logger: this.log, cid: this.id, logRaw: this.logRaw, secureConnection: this.secureConnection }); this.reading = false; this.socket = false; this.writeSocket = false; this.isClosed = false; this.states = states; this.state = this.states.NOT_AUTHENTICATED; this.lockCounter = 0; this.currentLock = false; this.tagCounter = 0; this.requestTagMap = new Map(); this.requestQueue = []; this.currentRequest = false; this.writeBytesCounter = 0; this.commandParts = []; /** * Active IMAP capabilities. Value is either `true` for togglabe capabilities (eg. `UIDPLUS`) * or a number for capabilities with a value (eg. `APPENDLIMIT`) * @type {Map<string, boolean|number>} */ this.capabilities = new Map(); this.authCapabilities = new Map(); this.rawCapabilities = null; this.expectCapabilityUpdate = false; // force CAPABILITY after LOGIN /** * Enabled capabilities. Usually `CONDSTORE` and `UTF8=ACCEPT` if server supports these. * @type {Set<string>} */ this.enabled = new Set(); /** * Is the connection currently usable or not * @type {Boolean} */ this.usable = false; /** * Currently authenticated user or `false` if mailbox is not open * or `true` if connection was authenticated by PREAUTH * @type {String|Boolean} */ this.authenticated = false; /** * Currently selected mailbox or `false` if mailbox is not open * @type {MailboxObject|Boolean} */ this.mailbox = false; this.currentSelectCommand = false; /** * Is current mailbox idling (`true`) or not (`false`) * @type {Boolean} */ this.idling = false; this.emitLogs = !!this.options.emitLogs; // ordering number for emitted logs this.lo = 0; this.untaggedHandlers = {}; this.sectionHandlers = {}; this.commands = imapCommands; this.folders = new Map(); this.currentLock = false; this.locks = []; this.idRequested = false; this.maxIdleTime = this.options.maxIdleTime || false; this.missingIdleCommand = (this.options.missingIdleCommand || '').toString().toUpperCase().trim() || 'NOOP'; this.disableBinary = !!this.options.disableBinary; this.streamer.on('error', err => { if (['Z_BUF_ERROR', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'EHOSTUNREACH'].includes(err.code)) { // just close the connection, usually nothing but noise return setImmediate(() => this.close()); } this.log.error({ err, cid: this.id }); this.emitError(err); }); // Has the `connect` method already been called this._connectCalled = false; } emitError(err) { if (!err) { return; } err._connId = err._connId || this.id; setImmediate(() => this.close()); this.emit('error', err); } getRandomId() { let rid = BigInt('0x' + crypto.randomBytes(13).toString('hex')).toString(36); if (rid.length < 20) { rid = '0'.repeat(20 - rid.length) + rid; } else if (rid.length > 20) { rid = rid.substr(0, 20); } return rid; } write(chunk) { if (!this.socket || this.socket.destroyed) { // do not write after connection end or logout const error = new Error('Socket is already closed'); error.code = 'NoConnection'; throw error; } if (this.state === this.states.LOGOUT) { // should not happen const error = new Error('Can not send data after logged out'); error.code = 'StateLogout'; throw error; } if (this.writeSocket.destroyed) { this.log.error({ msg: 'Write socket destroyed', cid: this.id }); this.close(); return; } let addLineBreak = !this.commandParts.length; if (typeof chunk === 'string') { if (addLineBreak) { chunk += '\r\n'; } chunk = Buffer.from(chunk, 'binary'); } else if (Buffer.isBuffer(chunk)) { if (addLineBreak) { chunk = Buffer.concat([chunk, Buffer.from('\r\n')]); } } else { return false; } if (this.logRaw) { this.log.trace({ src: 'c', msg: 'write to socket', data: chunk.toString('base64'), compress: !!this._deflate, secure: !!this.secureConnection, cid: this.id }); } this.writeBytesCounter += chunk.length; this.writeSocket.write(chunk); } stats(reset) { let result = { sent: this.writeBytesCounter || 0, received: (this.streamer && this.streamer.readBytesCounter) || 0 }; if (reset) { this.writeBytesCounter = 0; if (this.streamer) { this.streamer.readBytesCounter = 0; } } return result; } async send(data) { if (this.state === this.states.LOGOUT) { // already logged out if (data.tag) { let request = this.requestTagMap.get(data.tag); if (request) { this.requestTagMap.delete(request.tag); const error = new Error('Connection not available'); error.code = 'NoConnection'; request.reject(error); } } return; } let compiled = await compiler(data, { asArray: true, literalMinus: this.capabilities.has('LITERAL-') || this.capabilities.has('LITERAL+') }); this.commandParts = compiled; let logCompiled = await compiler(data, { isLogging: true }); let options = data.options || {}; this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, comment: options.comment }); this.write(this.commandParts.shift()); if (typeof options.onSend === 'function') { options.onSend(); } } async trySend() { if (this.currentRequest || !this.requestQueue.length) { return; } this.currentRequest = this.requestQueue.shift(); await this.send({ tag: this.currentRequest.tag, command: this.currentRequest.command, attributes: this.currentRequest.attributes, options: this.currentRequest.options }); } async exec(command, attributes, options) { if (this.state === this.states.LOGOUT || this.isClosed) { const error = new Error('Connection not available'); error.code = 'NoConnection'; throw error; } if (!this.socket || this.socket.destroyed) { let error = new Error('Connection closed'); error.code = 'EConnectionClosed'; throw error; } let tag = (++this.tagCounter).toString(16).toUpperCase(); options = options || {}; return new Promise((resolve, reject) => { this.requestTagMap.set(tag, { command, attributes, options, resolve, reject }); this.requestQueue.push({ tag, command, attributes, options }); this.trySend().catch(err => { this.requestTagMap.delete(tag); reject(err); }); }); } getUntaggedHandler(command, attributes) { if (/^[0-9]+$/.test(command)) { let type = attributes && attributes.length && typeof attributes[0].value === 'string' ? attributes[0].value.toUpperCase() : false; if (type) { // EXISTS, EXPUNGE, RECENT, FETCH etc command = type; } } command = command.toUpperCase().trim(); if (this.currentRequest && this.currentRequest.options && this.currentRequest.options.untagged && this.currentRequest.options.untagged[command]) { return this.currentRequest.options.untagged[command]; } if (this.untaggedHandlers[command]) { return this.untaggedHandlers[command]; } } getSectionHandler(key) { if (this.sectionHandlers[key]) { return this.sectionHandlers[key]; } } async reader() { let data; while ((data = this.streamer.read()) !== null) { let parsed; try { parsed = await parser(data.payload, { literals: data.literals }); if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) { let payload = { response: parsed.command }; if ( parsed.attributes && parsed.attributes[0] && parsed.attributes[0].section && parsed.attributes[0].section[0] && parsed.attributes[0].section[0].type === 'ATOM' ) { payload.code = parsed.attributes[0].section[0].value; } this.emit('response', payload); } } catch (err) { // can not make sense of this this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id }); data.next(); continue; } let logCompiled = await compiler(parsed, { isLogging: true }); if (/^\d+$/.test(parsed.command) && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH') { // too many FETCH responses, might want to filter these out this.log.trace({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved }); } else { this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved }); } if (parsed.tag === '+' && this.currentRequest && this.currentRequest.options && typeof this.currentRequest.options.onPlusTag === 'function') { await this.currentRequest.options.onPlusTag(parsed); data.next(); continue; } if (parsed.tag === '+' && this.commandParts.length) { let content = this.commandParts.shift(); this.write(content); this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id }); data.next(); continue; } let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section; if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') { let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim()); if (sectionHandler) { await sectionHandler(section.slice(1)); } } if (parsed.tag === '*' && parsed.command) { let untaggedHandler = this.getUntaggedHandler(parsed.command, parsed.attributes); if (untaggedHandler) { try { await untaggedHandler(parsed); } catch (err) { this.log.warn({ err, cid: this.id }); data.next(); continue; } } } if (this.requestTagMap.has(parsed.tag)) { let request = this.requestTagMap.get(parsed.tag); this.requestTagMap.delete(parsed.tag); if (this.currentRequest && this.currentRequest.tag === parsed.tag) { // send next pending command this.currentRequest = false; await this.trySend(); } switch (parsed.command.toUpperCase()) { case 'OK': case 'BYE': await new Promise(resolve => request.resolve({ response: parsed, next: resolve })); break; case 'NO': case 'BAD': { let txt = parsed.attributes && parsed.attributes .filter(val => val.type === 'TEXT') .map(val => val.value.trim()) .join(' '); let err = new Error('Command failed'); err.response = parsed; err.responseStatus = parsed.command.toUpperCase(); try { err.executedCommand = parsed.tag + ( await compiler(request, { isLogging: true }) ).toString(); } catch (err) { // ignore } if (txt) { err.responseText = txt; if (err.responseStatus === 'NO' && txt.includes('Some of the requested messages no longer exist')) { // Treat as successful response this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err }); await new Promise(resolve => request.resolve({ response: parsed, next: resolve })); break; } let throttleDelay = false; // MS365 throttling // tag BAD Request is throttled. Suggested Backoff Time: 92415 milliseconds if (/Request is throttled/i.test(txt) && /Backoff Time/i.test(txt)) { let throttlingMatch = txt.match(/Backoff Time[:=\s]+(\d+)/i); if (throttlingMatch && throttlingMatch[1] && !isNaN(throttlingMatch[1])) { throttleDelay = Number(throttlingMatch[1]); } } // Wait and return a throttling error if (throttleDelay) { err.code = 'ETHROTTLE'; err.throttleReset = throttleDelay; let delayResponse = throttleDelay; if (delayResponse > 5 * 60 * 1000) { // max delay cap delayResponse = 5 * 60 * 1000; } this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err }); await new Promise(r => setTimeout(r, delayResponse)); } } request.reject(err); break; } default: { let err = new Error('Invalid server response'); err.code = 'InvalidResponse'; err.response = parsed; request.reject(err); break; } } } data.next(); } } setEventHandlers() { this.socketReadable = () => { if (!this.reading) { this.reading = true; this.reader() .catch(err => this.log.error({ err, cid: this.id })) .finally(() => { this.reading = false; }); } }; this.streamer.on('readable', this.socketReadable); } setSocketHandlers() { // Clear any existing handlers first to prevent duplicates this.clearSocketHandlers(); this._socketError = this._socketError || (err => { this.log.error({ err, cid: this.id }); this.emitError(err); }); this._socketClose = this._socketClose || (() => { this.close(); }); this._socketEnd = this._socketEnd || (() => { this.close(); }); /** * Socket timeout event handler. * * When a socket timeout occurs during IDLE, the handler attempts to recover the connection * by sending a NOOP command and then returning to IDLE state. * * @fires ImapFlow#error Emits error event unless the current command is IDLE */ this._socketTimeout = this._socketTimeout || (() => { const err = new Error('Socket timeout'); err.code = 'ETIMEOUT'; if (this.idling) { if (!this.usable || !this.socket || this.socket.destroyed) { this.emitError(err); return; } // Attempt to recover IDLE connections this.run('NOOP') .then(() => this.idle()) .catch(this._socketError); // Natural circuit breaker } else { // Close immediately for non-IDLE operations this.log.debug({ msg: 'Socket timeout', cid: this.id }); this.emitError(err); } }); this.socket.once('error', this._socketError); this.socket.once('close', this._socketClose); this.socket.once('end', this._socketEnd); this.socket.on('tlsClientError', this._socketError); this.socket.on('timeout', this._socketTimeout); if (this.writeSocket && this.writeSocket !== this.socket) { this.writeSocket.on('error', this._socketError); } } clearSocketHandlers() { if (!this.socket) { return; } if (this._socketError) { this.socket.removeListener('error', this._socketError); this.socket.removeListener('tlsClientError', this._socketError); if (this.writeSocket && this.writeSocket !== this.socket) { this.writeSocket.removeListener('error', this._socketError); } } if (this._socketTimeout) { this.socket.removeListener('timeout', this._socketTimeout); } if (this._socketClose) { this.socket.removeListener('close', this._socketClose); } if (this._socketEnd) { this.socket.removeListener('end', this._socketEnd); } } async startSession() { await this.run('CAPABILITY'); if (this.capabilities.has('ID')) { this.idRequested = await this.run('ID', this.clientInfo); } await this.upgradeToSTARTTLS(); await this.authenticate(); if (!this.idRequested && this.capabilities.has('ID')) { // re-request ID after LOGIN this.idRequested = await this.run('ID', this.clientInfo); } // Make sure we have namespace set. This should also throw if Exchange actually failed authentication let nsResponse = await this.run('NAMESPACE'); if (nsResponse && nsResponse.error && nsResponse.status === 'BAD' && /User is authenticated but not connected/i.test(nsResponse.text)) { // Not a NAMESPACE failure but authentication failure, so report as this.authenticated = false; let err = new AuthenticationFailure('Authentication failed'); err.response = nsResponse.text; throw err; } if (this.options.verifyOnly) { // List all folders and logout if (this.options.includeMailboxes) { this._mailboxList = await this.list(); } return await this.logout(); } // try to use compression (if supported) if (!this.options.disableCompression) { await this.compress(); } if (!this.options.disableAutoEnable) { // enable extensions if possible await this.run('ENABLE', ['CONDSTORE', 'UTF8=ACCEPT'].concat(this.options.qresync ? 'QRESYNC' : [])); } this.usable = true; } async compress() { if (!(await this.run('COMPRESS'))) { return; // was not able to negotiate compression } // create deflate/inflate streams this._deflate = zlib.createDeflateRaw({ windowBits: 15 }); this._inflate = zlib.createInflateRaw(); // route incoming socket via inflate stream this.socket.unpipe(this.streamer); this.streamer.compress = true; this.socket.pipe(this._inflate).pipe(this.streamer); this._inflate.on('error', err => { this.streamer.emit('error', err); }); // route outgoing socket via deflate stream this.writeSocket = new PassThrough(); this.writeSocket.destroySoon = () => { try { if (this.socket) { this.socket.destroy(); } this.writeSocket.end(); } catch (err) { this.log.error({ err, info: 'Failed to destroy PassThrough socket', cid: this.id }); throw err; } }; Object.defineProperty(this.writeSocket, 'destroyed', { get: () => !this.socket || this.socket.destroyed }); // we need to force flush deflated data to socket so we can't // use normal pipes for this.writeSocket -> this._deflate -> this.socket let reading = false; let readNext = () => { reading = true; let chunk; while ((chunk = this.writeSocket.read()) !== null) { if (this._deflate && this._deflate.write(chunk) === false) { return this._deflate.once('drain', readNext); } } // flush data to socket if (this._deflate) { this._deflate.flush(); } reading = false; }; this.writeSocket.on('readable', () => { if (!reading) { readNext(); } }); this.writeSocket.on('error', err => { this.socket.emit('error', err); }); this._deflate.pipe(this.socket); this._deflate.on('error', err => { this.socket.emit('error', err); }); } _failSTARTTLS() { if (this.options.doSTARTTLS === true) { // STARTTLS configured as requirement let err = new Error('Server does not support STARTTLS'); err.tlsFailed = true; throw err; } else { // Opportunistic STARTTLS. But it's not possible right now. // Attention: Could be a downgrade attack. return false; } } /** * Tries to upgrade the connection to TLS using STARTTLS. * @throws if STARTTLS is required, but not possible. * @returns {boolean} true, if the connection is now protected by TLS, either direct TLS or STARTTLS. */ async upgradeToSTARTTLS() { if (this.options.doSTARTTLS === true && this.options.secure === true) { throw new Error('Misconfiguration: Cannot set both secure=true for TLS and doSTARTTLS=true for STARTTLS.'); } if (this.secureConnection) { // Already using direct TLS. No need for STARTTLS. return true; } if (this.options.doSTARTTLS === false) { // STARTTLS explictly disabled by config return false; } if (!this.capabilities.has('STARTTLS')) { return this._failSTARTTLS(); } this.expectCapabilityUpdate = true; let canUpgrade = await this.run('STARTTLS'); if (!canUpgrade) { return this._failSTARTTLS(); } this.socket.unpipe(this.streamer); let upgraded = await new Promise((resolve, reject) => { let socketPlain = this.socket; let opts = Object.assign( { socket: this.socket, servername: this.servername, port: this.port }, this.options.tls || {} ); this.clearSocketHandlers(); socketPlain.once('error', err => { clearTimeout(this.connectTimeout); clearTimeout(this.upgradeTimeout); if (!this.upgrading) { // don't care anymore return; } setImmediate(() => this.close()); this.upgrading = false; err.tlsFailed = true; reject(err); }); this.upgradeTimeout = setTimeout(() => { if (!this.upgrading) { return; } setImmediate(() => this.close()); let err = new Error('Failed to upgrade connection in required time'); err.tlsFailed = true; err.code = 'UPGRADE_TIMEOUT'; reject(err); }, UPGRADE_TIMEOUT); this.upgrading = true; this.socket = tls.connect(opts, () => { clearTimeout(this.upgradeTimeout); if (this.isClosed) { // not sure if this is possible? return this.close(); } this.secureConnection = true; this.upgrading = false; this.streamer.secureConnection = true; this.socket.pipe(this.streamer); this.tls = typeof this.socket.getCipher === 'function' ? this.socket.getCipher() : false; if (this.tls) { this.tls.authorized = this.socket.authorized; this.log.info({ src: 'tls', msg: 'Established TLS session', cid: this.id, authorized: this.tls.authorized, algo: this.tls.standardName || this.tls.name, version: this.tls.version }); } return resolve(true); }); this.writeSocket = this.socket; this.setSocketHandlers(); }); if (upgraded && this.expectCapabilityUpdate) { await this.run('CAPABILITY'); } return upgraded; } async setAuthenticationState() { this.state = this.states.AUTHENTICATED; this.authenticated = true; if (this.expectCapabilityUpdate) { // update capabilities await this.run('CAPABILITY'); } } async authenticate() { if (this.state === this.states.LOGOUT) { throw new AuthenticationFailure('Already logged out'); } if (this.state !== this.states.NOT_AUTHENTICATED) { // nothing to do here, usually happens with PREAUTH greeting return true; } if (!this.options.auth) { throw new AuthenticationFailure('Please configure the login'); } this.expectCapabilityUpdate = true; let loginMethod = (this.options.auth.loginMethod || '').toString().trim().toUpperCase(); if (!loginMethod && /\\|\//.test(this.options.auth.user)) { // Special override for MS Exchange when authenticating as some other user or non-email account loginMethod = 'LOGIN'; } if (this.options.auth.accessToken) { this.authenticated = await this.run('AUTHENTICATE', this.options.auth.user, { accessToken: this.options.auth.accessToken }); } else if (this.options.auth.pass) { if ((this.capabilities.has('AUTH=LOGIN') || this.capabilities.has('AUTH=PLAIN')) && loginMethod !== 'LOGIN') { this.authenticated = await this.run('AUTHENTICATE', this.options.auth.user, { password: this.options.auth.pass, loginMethod }); } else { if (this.capabilities.has('LOGINDISABLED')) { throw new AuthenticationFailure('Login is disabled'); } this.authenticated = await this.run('LOGIN', this.options.auth.user, this.options.auth.pass); } } else { throw new AuthenticationFailure('No password configured'); } if (this.authenticated) { this.log.info({ src: 'auth', msg: 'User authenticated', cid: this.id, user: this.options.auth.user }); await this.setAuthenticationState(); return true; } throw new AuthenticationFailure('No matching authentication method'); } async initialOK(message) { this.greeting = (message.attributes || []) .filter(entry => entry.type === 'TEXT') .map(entry => entry.value) .filter(entry => entry) .join(''); clearTimeout(this.greetingTimeout); this.untaggedHandlers.OK = null; this.untaggedHandlers.PREAUTH = null; if (this.isClosed) { return; } // get out of current parsing "thread", so do not await for startSession this.startSession() .then(() => { if (typeof this.initialResolve === 'function') { let resolve = this.initialResolve; this.initialResolve = false; this.initialReject = false; return resolve(); } }) .catch(err => { this.log.error({ err, cid: this.id }); if (typeof this.initialReject === 'function') { clearTimeout(this.greetingTimeout); let reject = this.initialReject; this.initialResolve = false; this.initialReject = false; return reject(err); } // ALWAYS emit the error so users can handle it this.emitError(err); }); } async initialPREAUTH() { clearTimeout(this.greetingTimeout); this.untaggedHandlers.OK = null; this.untaggedHandlers.PREAUTH = null; if (this.isClosed) { return; } this.state = this.states.AUTHENTICATED; // get out of current parsing "thread", so do not await for startSession this.startSession() .then(() => { if (typeof this.initialResolve === 'function') { let resolve = this.initialResolve; this.initialResolve = false; this.initialReject = false; return resolve(); } }) .catch(err => { this.log.error({ err, cid: this.id }); if (typeof this.initialReject === 'function') { clearTimeout(this.greetingTimeout); let reject = this.initialReject; this.initialResolve = false; this.initialReject = false; return reject(err); } setImmediate(() => this.close()); }); } async serverBye() { this.untaggedHandlers.BYE = null; this.state = this.states.LOGOUT; } async sectionCapability(section) { this.rawCapabilities = section; this.capabilities = updateCapabilities(section); if (this.capabilities) { for (let [capa] of this.capabilities) { if (/^AUTH=/i.test(capa) && !this.authCapabilities.has(capa.toUpperCase())) { this.authCapabilities.set(capa.toUpperCase(), false); } } } if (this.expectCapabilityUpdate) { this.expectCapabilityUpdate = false; } } async untaggedCapability(untagged) { this.rawCapabilities = untagged.attributes; this.capabilities = updateCapabilities(untagged.attributes); if (this.capabilities) { for (let [capa] of this.capabilities) { if (/^AUTH=/i.test(capa) && !this.authCapabilities.has(capa.toUpperCase())) { this.authCapabilities.set(capa.toUpperCase(), false); } } } if (this.expectCapabilityUpdate) { this.expectCapabilityUpdate = false; } } async untaggedExists(untagged) { if (!this.mailbox) { // mailbox closed, ignore return; } if (!untagged || !untagged.command || isNaN(untagged.command)) { return; } let count = Number(untagged.command); if (count === this.mailbox.exists) { // nothing changed? return; } // keep exists up to date let prevCount = this.mailbox.exists; this.mailbox.exists = count; this.emit('exists', { path: this.mailbox.path, count, prevCount }); } async untaggedExpunge(untagged) { if (!this.mailbox) { // mailbox closed, ignore return; } if (!untagged || !untagged.command || isNaN(untagged.command)) { return; } let seq = Number(untagged.command); if (seq && seq <= this.mailbox.exists) { this.mailbox.exists--; let payload = { path: this.mailbox.path, seq, vanished: false }; if (typeof this.options.expungeHandler === 'function') { try { await this.options.expungeHandler(payload); } catch (err) { this.log.error({ msg: 'Failed to notify expunge event', payload, error: err, cid: this.id }); } } else { this.emit('expunge', payload); } } } async untaggedVanished(untagged, mailbox) { mailbox = mailbox || this.mailbox; if (!mailbox) { // mailbox closed, ignore return; } let tags = []; let uids = false; if (untagged.attributes.length > 1 && Array.isArray(untagged.attributes[0])) { tags = untagged.attributes[0].map(entry => (typeof entry.value === 'string' ? entry.value.toUpperCase() : false)).filter(value => value); untagged.attributes.shift(); } if (untagged.attributes[0] && typeof untagged.attributes[0].value === 'string') { uids = untagged.attributes[0].value; } let uidList = expandRange(uids); for (let uid of uidList) { let payload = { path: mailbox.path, uid, vanished: true, earlier: tags.includes('EARLIER') }; if (typeof this.options.expungeHandler === 'function') { try { await this.options.expungeHandler(payload); } catch (err) { this.log.error({ msg: 'Failed to notify expunge event', payload, error: err, cid: this.id }); } } else { this.emit('expunge', payload); } } } async untaggedFetch(untagged, mailbox) { mailbox = mailbox || this.mailbox; if (!mailbox) { // mailbox closed, ignore return; } let message = await formatMessageResponse(untagged, mailbox); if (message.flags) { let updateEvent = { path: mailbox.path, seq: message.seq }; if (message.uid) { updateEvent.uid = message.uid; } if (message.modseq) { updateEvent.modseq = message.modseq; } updateEvent.flags = message.flags; if (message.flagColor) { updateEvent.flagColor = message.flagColor; } this.emit('flags', updateEvent); } } async ensureSelectedMailbox(path) { if (!path) { return false; } if ((!this.mailbox && path) || (this.mailbox && path && !comparePaths(this, this.mailbox.path, path))) { return await this.mailboxOpen(path); } return true; } async resolveRange(range, options) { if (typeof range === 'number' || typeof range === 'bigint') { range = range.toString(); } // special case, some servers allow this, some do not, so replace it with the last known EXISTS value if (range === '*') { if (!this.mailbox.exists) { return false; } range = this.mailbox.exists.toString(); options.uid = false; // sequence query } if (range && typeof range === 'object' && !Array.isArray(range)) { if (range.all && Obje