imapflow
Version:
IMAP Client for Node
1,417 lines (1,204 loc) • 134 kB
JavaScript
'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