wildduck/imap-core/lib/imap-connection.js
Andris Reinman 2a24ff9331 v1.26.3
2020-05-27 11:55:36 +03:00

918 lines
29 KiB
JavaScript

'use strict';
const IMAPStream = require('./imap-stream').IMAPStream;
const IMAPCommand = require('./imap-command').IMAPCommand;
const IMAPComposer = require('./imap-composer').IMAPComposer;
const imapTools = require('./imap-tools');
const search = require('./search');
const dns = require('dns');
const crypto = require('crypto');
const os = require('os');
const base32 = require('base32.js');
const EventEmitter = require('events').EventEmitter;
const packageInfo = require('../../package');
const errors = require('../../lib/errors.js');
const SOCKET_TIMEOUT = 5 * 60 * 1000;
/**
* Creates a handler for new socket
*
* @constructor
* @param {Object} server Server instance
* @param {Object} socket Socket instance
*/
class IMAPConnection extends EventEmitter {
constructor(server, socket, options) {
super();
options = options || {};
// Random session ID, used for logging
this.id = options.id || base32.encode(crypto.randomBytes(10)).toLowerCase();
this.ignore = options.ignore;
this.compression = false;
this._deflate = false;
this._inflate = false;
this._server = server;
this._socket = socket;
this.writeStream = new IMAPComposer({
connection: this,
skipFetchLog: server.skipFetchLog
});
this.writeStream.pipe(this._socket);
this.writeStream.on('error', this._onError.bind(this));
// session data (envelope, user etc.)
this.session = false;
// If true then the connection is currently being upgraded to TLS
this._upgrading = false;
// Parser instance for the incoming stream
this._parser = new IMAPStream();
// Set handler for incoming commands
this._parser.oncommand = this._onCommand.bind(this);
// Manage multi part command
this._currentCommand = false;
// If set, then data payload is not executed as a command but as an argument for this function
this._nextHandler = false;
// If true, then the connection is using TLS
this.secure = !!this._server.options.secure;
// Store remote address for later usage
this.remoteAddress = (options.remoteAddress || this._socket.remoteAddress || '').replace(/^::ffff:/, '');
// Server hostname for the greegins
this.name = (this._server.options.name || os.hostname()).toLowerCase();
this.state = 'Not Authenticated';
this._listenerData = false;
// selected mailbox metadata
this.selected = false;
// ignore timeouts if true
this.idling = false;
// indicates if CONDSTORE is enabled for the session
this.condstoreEnabled = false;
// Resolved hostname for remote IP address
this.clientHostname = false;
// increment connection count
this._closing = false;
this._closed = false;
this.logger = {};
['info', 'debug', 'error'].forEach(level => {
this.logger[level] = (...args) => {
if (!this.ignore) {
this._server.logger[level](...args);
}
};
});
this.loggelf = () => false;
}
/**
* Initiates the connection. Checks connection limits and reverse resolves client hostname. The client
* is not allowed to send anything before init has finished otherwise 'You talk too soon' error is returned
*/
init() {
// Setup event handlers for the socket
this._setListeners();
// make sure we have a session set up
this._startSession();
let now = Date.now();
let greetingSent = false;
let sendGreeting = () => {
if (greetingSent) {
return;
}
greetingSent = true;
this.logger.info(
{
tnx: 'connect',
cid: this.id
},
'[%s] %s from %s to %s:%s',
this.id,
this.secure ? 'Secure connection' : 'Connection',
this.session.clientHostname,
this._socket && this._socket.localAddress,
this._socket && this._socket.localPort
);
this.send(
'* OK ' +
((this._server.options.id && this._server.options.id.name) || packageInfo.name) +
' ready for requests from ' +
this.remoteAddress +
' ' +
this.id
);
};
// do not wait with initial response too long
let resolveTimer = setTimeout(() => {
clearTimeout(resolveTimer);
sendGreeting();
}, 1000);
let reverseCb = (err, hostnames) => {
clearTimeout(resolveTimer);
if (err) {
//ignore, no big deal
}
let clientHostname = hostnames && hostnames.shift();
this.session.clientHostname = this.clientHostname = clientHostname || '[' + this.remoteAddress + ']';
if (greetingSent && clientHostname) {
this.logger.info(
{
tnx: 'connect',
cid: this.id
},
'[%s] Resolved %s as %s in %ss',
this.id,
this.remoteAddress,
clientHostname,
((Date.now() - now) / 1000).toFixed(3)
);
}
// eslint-disable-line handle-callback-err
if (this._closing || this._closed) {
return;
}
sendGreeting();
};
// Resolve hostname for the remote IP
// we do not care for errors as we consider the ip as unresolved in this case, no big deal
try {
dns.reverse(this.remoteAddress, reverseCb);
} catch (E) {
// happens on invalid remote address
reverseCb(E);
}
}
/**
* Send data to socket
*
* @param {Number} code Response code
* @param {String|Array} data If data is Array, send a multi-line response
*/
send(payload, callback) {
if (this._socket && this._socket.writable) {
try {
this[!this.compression ? '_socket' : '_deflate'].write(payload + '\r\n', 'binary', (...args) => {
if (args[0]) {
// write error
this.logger.error(
{
tnx: 'send',
cid: this.id,
err: args[0]
},
'[%s] Send error: %s',
this.id,
args[0].message || args[0]
);
return this.close();
}
if (typeof callback === 'function') {
return callback(...args);
}
});
} catch (err) {
// write error
this.logger.error(
{
tnx: 'send',
cid: this.id,
err
},
'[%s] Send error: %s',
this.id,
err.message || err
);
return this.close();
}
if (this.compression) {
// make sure we transmit the message immediatelly
this._deflate.flush();
}
this.logger.debug(
{
tnx: 'send',
cid: this.id
},
'[%s] S:',
this.id,
payload
);
} else {
// socket is not there anymore
this.close();
}
}
/**
* Close socket
*/
close(force) {
if (this._closed || this._closing) {
return;
}
if (!this._socket.destroyed && this._socket.writable) {
this._socket[!force ? 'end' : 'destroy']();
}
this._server.connections.delete(this);
if (!force) {
// allow socket to close in 1500ms or force it to close
this._closingTimeout = setTimeout(() => {
if (this._closed) {
return;
}
try {
this._socket.destroy();
} catch (err) {
// ignore
}
setImmediate(() => this._onClose());
}, 1500);
}
this._closing = true;
if (force) {
setImmediate(() => this._onClose());
}
}
// PRIVATE METHODS
/**
* Setup socket event handlers
*/
_setListeners() {
this._socket.on('close', this._onClose.bind(this));
this._socket.on('end', this._onEnd.bind(this));
this._socket.on('error', this._onError.bind(this));
this._socket.setTimeout(this._server.options.socketTimeout || SOCKET_TIMEOUT, this._onTimeout.bind(this));
this._socket.pipe(this._parser);
}
/**
* Fired when the socket is closed
* @event
*/
_onEnd() {
if (!this._closed) {
this._onClose();
}
}
/**
* Fired when the socket is closed
* @event
*/
_onClose(/* hadError */) {
clearTimeout(this._closingTimeout);
if (this._closed) {
return;
}
this._parser = false;
this.state = 'Closed';
if (this._dataStream) {
this._dataStream.unpipe();
this._dataStream = null;
}
if (this._deflate) {
this._deflate = null;
}
if (this._inflate) {
this._inflate = null;
}
this.clearNotificationListener();
this._server.connections.delete(this);
if (this.user) {
if (typeof this._server.notifier.releaseConnection === 'function') {
this._server.notifier.releaseConnection(
{
service: 'imap',
session: this.session,
user: this.user
},
() => false
);
}
this.loggelf({
short_message: '[CONNRELEASE] Connection released for ' + this.user.id,
_connection: 'release',
_service: 'imap',
_sess: this.session && this.session.id,
_user: this.user.id,
_cid: this.id,
_ip: this.remoteAddress
});
}
this._closed = true;
this._closing = false;
this.logger.info(
{
tnx: 'close',
cid: this.id
},
'[%s] Connection closed to %s',
this.id,
this.clientHostname
);
}
/**
* Fired when an error occurs with the socket
*
* @event
* @param {Error} err Error object
*/
_onError(err) {
if (err.processed) {
return;
}
if (['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'EHOSTUNREACH'].includes(err.code)) {
this.logger.info(
{
tnx: 'connection',
cid: this.id
},
'[%s] Closing connection due to %s',
this.id,
err.code
);
this.close(); // mark connection as 'closing'
return;
}
if (err && /SSL[23]*_GET_CLIENT_HELLO|ssl[23]*_read_bytes|ssl_bytes_to_cipher_list/i.test(err.message)) {
let message = err.message;
err.message = 'Failed to establish TLS session';
err.code = err.code || 'TLSError';
err.meta = {
protocol: 'imap',
stage: 'starttls',
message,
remoteAddress: this.remoteAddress
};
}
if (!err || !err.message) {
err = new Error('Socket closed unexpectedly');
err.code = 'SocketError';
err.meta = {
remoteAddress: this.remoteAddress
};
}
errors.notifyConnection(this.this, err);
this.logger.error(
{
err,
cid: this.id
},
'[%s] %s',
this.id,
err.message
);
this.emit('error', err);
}
/**
* Fired when socket timeouts. Closes connection
*
* @event
*/
_onTimeout() {
this.logger.info(
{
tnx: 'connection',
cid: this.id
},
'[%s] Connection TIMEOUT',
this.id
);
if (this.idling) {
// see if the connection still works
this.send('* OK Still here (' + Date.now() + ')');
this._socket.setTimeout(this._server.options.socketTimeout || SOCKET_TIMEOUT, this._onTimeout.bind(this));
return;
}
this.send('* BYE Idle timeout, closing connection');
setImmediate(() => this.close(true));
}
/**
* Checks if a selected command is available and ivokes it
*
* @param {Buffer} command Single line of data from the client
* @param {Function} callback Callback to run once the command is processed
*/
_onCommand(command, callback) {
let currentCommand = this._currentCommand;
callback = callback || (() => false);
if (this._upgrading) {
// ignore any commands before TLS upgrade is finished
return callback();
}
if (!currentCommand) {
this._currentCommand = currentCommand = new IMAPCommand(this);
}
if (!command.final) {
currentCommand.append(command, callback);
} else {
this._currentCommand = false;
currentCommand.end(command, callback);
}
}
/**
* Sets up a new session
*/
_startSession() {
this.session = {
id: this.id,
selected: this.selected,
remoteAddress: this.remoteAddress,
clientHostname: this.clientHostname || '[' + this.remoteAddress + ']',
writeStream: this.writeStream,
socket: this._socket,
formatResponse: this.formatResponse.bind(this),
getQueryResponse: imapTools.getQueryResponse,
matchSearchQuery: search.matchSearchQuery,
commandCounters: {},
isUTF8Enabled: () => this.acceptUTF8Enabled
};
}
/**
* Sets up notification listener from upstream
*/
setupNotificationListener() {
let conn = this;
if (this._closing || this._closed) {
// nothing to do here
return;
}
let isSelected = mailbox => mailbox && conn.selected && conn.selected.mailbox && conn.selected.mailbox.toString() === mailbox.toString();
this._listenerData = {
lock: false,
cleared: false,
callback(message) {
let selectedMailbox = conn.selected && conn.selected.mailbox;
if (this._closing || this._closed) {
conn.clearNotificationListener();
return;
}
if (message) {
// global triggers
switch (message.command) {
case 'LOGOUT':
conn.clearNotificationListener();
conn.send('* BYE ' + (message.reason || 'Logout requested'));
setImmediate(() => conn.close());
break;
case 'DROP':
if (isSelected(message.mailbox)) {
conn.clearNotificationListener();
conn.send('* BYE Selected mailbox was deleted, have to disconnect');
setImmediate(() => conn.close());
break;
}
}
return;
}
if (conn._listenerData.lock || !selectedMailbox) {
// race condition, do not allow fetching data before previous fetch is finished
return;
}
conn._listenerData.lock = true;
conn._server.notifier.getUpdates(selectedMailbox, conn.selected.modifyIndex, (err, updates) => {
if (!conn._listenerData || conn._listenerData.cleared) {
// already logged out
return;
}
conn._listenerData.lock = false;
if (err) {
conn.logger.info(
{
err,
tnx: 'updates',
cid: conn.id
},
'[%s] Notification Error: %s',
conn.id,
err.message
);
return;
}
// check if the same mailbox is still selected
if (!isSelected(selectedMailbox) || !updates || !updates.length) {
return;
}
updates.sort((a, b) => a.modseq - b.modseq);
// store new incremental modify index
if (updates[updates.length - 1].modseq > conn.selected.modifyIndex) {
conn.selected.modifyIndex = updates[updates.length - 1].modseq;
}
// append received notifications to the list
conn.selected.notifications = conn.selected.notifications.concat(updates);
if (conn.idling) {
// when idling emit notifications immediatelly
conn.emitNotifications();
}
});
}
};
this._server.notifier.addListener(this.session, this._listenerData.callback);
}
clearNotificationListener() {
if (!this._listenerData || this._listenerData.cleared) {
return;
}
this._server.notifier.removeListener(this.session, this._listenerData.callback);
this._listenerData.cleared = true;
this._listenerData = false;
}
// send notifications to client
emitNotifications() {
if (this.state !== 'Selected' || !this.selected || !this.selected.notifications.length) {
return;
}
let changed = false;
let existsResponse;
// show notifications
this.logger.debug(
{
tnx: 'notifications',
cid: this.id
},
'[%s] Pending notifications: %s',
this.id,
this.selected.notifications.length
);
// find UIDs that are both added and removed
let added = new Set(); // added UIDs
let removed = new Set(); // removed UIDs
let skip = new Set(); // UIDs that are removed before ever seen
for (let i = 0, len = this.selected.notifications.length; i < len; i++) {
let update = this.selected.notifications[i];
if (update.command === 'EXISTS') {
added.add(update.uid);
} else if (update.command === 'EXPUNGE') {
removed.add(update.uid);
}
}
removed.forEach(uid => {
if (added.has(uid)) {
skip.add(uid);
}
});
// filter multiple FETCH calls, only keep latest, otherwise might mess up MODSEQ responses
let fetches = new Set();
for (let i = this.selected.notifications.length - 1; i >= 0; i--) {
let update = this.selected.notifications[i];
if (update.command === 'FETCH') {
// skip multiple flag updates and updates for removed or newly added messages
if (fetches.has(update.uid) || added.has(update.uid) || removed.has(update.uid)) {
this.selected.notifications.splice(i, 1);
} else {
fetches.add(update.uid);
}
}
}
for (let i = 0, len = this.selected.notifications.length; i < len; i++) {
let update = this.selected.notifications[i];
// skip unnecessary entries that are already removed
if (skip.has(update.uid)) {
continue;
}
if (update.modseq > this.selected.modifyIndex) {
this.selected.modifyIndex = update.modseq;
}
this.logger.debug(
{
tnx: 'notifications',
cid: this.id
},
'[%s] Processing notification: %s',
this.id,
JSON.stringify(update)
);
if (update.ignore === this.id) {
continue; // skip this
}
this.logger.debug(
{
tnx: 'notifications',
cid: this.id
},
'[%s] UIDS: %s',
this.id,
this.selected.uidList.length
);
switch (update.command) {
case 'EXISTS':
// Generate the response but do not send it yet (EXIST response generation is needed to modify the UID list)
// This way we can accumulate consecutive EXISTS responses into single one as
// only the last one actually matters to the client
existsResponse = this.formatResponse('EXISTS', update.uid);
changed = false;
break;
case 'EXPUNGE': {
let seq = (this.selected.uidList || []).indexOf(update.uid);
this.logger.debug(
{
tnx: 'expunge',
cid: this.id
},
'[%s] EXPUNGE %s',
this.id,
seq
);
if (seq >= 0) {
let output = this.formatResponse('EXPUNGE', update.uid);
this.writeStream.write(output);
changed = true; // if no more EXISTS after this, then generate an additional EXISTS
}
break;
}
case 'FETCH':
this.writeStream.write(
this.formatResponse('FETCH', update.uid, {
flags: update.flags,
modseq: (this.selected.condstoreEnabled && update.modseq) || false
})
);
break;
}
}
if (existsResponse && !changed) {
// send cached EXISTS response
this.writeStream.write(existsResponse);
existsResponse = false;
}
if (changed) {
this.writeStream.write({
tag: '*',
command: String(this.selected.uidList.length),
attributes: [
{
type: 'atom',
value: 'EXISTS'
}
]
});
}
// clear queue
this.selected.notifications = [];
}
formatResponse(command, uid, data) {
command = command.toUpperCase();
let seq;
if (command === 'EXISTS') {
this.selected.uidList.push(uid);
seq = this.selected.uidList.length;
} else {
seq = (this.selected.uidList || []).indexOf(uid);
if (seq < 0) {
return false;
}
seq++;
}
if (command === 'EXPUNGE') {
this.selected.uidList.splice(seq - 1, 1);
}
let response = {
tag: '*',
command: String(seq),
attributes: [
{
type: 'atom',
value: command
}
]
};
if (data) {
response.attributes.push([]);
if ('query' in data) {
// Response for FETCH command
data.query.forEach((item, i) => {
response.attributes[1].push(item.original);
if (['flags', 'modseq'].indexOf(item.item) >= 0) {
response.attributes[1].push(
[].concat(data.values[i] || []).map(value => ({
type: 'ATOM',
value: (value || value === 0 ? value : '').toString()
}))
);
} else if (Object.prototype.toString.call(data.values[i]) === '[object Date]') {
response.attributes[1].push({
type: 'ATOM',
value: imapTools.formatInternalDate(data.values[i])
});
} else if (Array.isArray(data.values[i])) {
response.attributes[1].push(data.values[i]);
} else if (item.isLiteral) {
if (data.values[i] && data.values[i].type === 'stream') {
response.attributes[1].push({
type: 'LITERAL',
value: data.values[i].value,
expectedLength: data.values[i].expectedLength,
startFrom: data.values[i].startFrom,
maxLength: data.values[i].maxLength
});
} else {
response.attributes[1].push({
type: 'LITERAL',
value: data.values[i]
});
}
} else if (data.values[i] === '') {
response.attributes[1].push(data.values[i]);
} else {
response.attributes[1].push({
type: 'ATOM',
value: data.values[i].toString()
});
}
});
} else {
// Notification response
Object.keys(data).forEach(key => {
let value = data[key];
key = key.toUpperCase();
if (!value) {
return;
}
switch (key) {
case 'FLAGS':
value = [].concat(value || []).map(flag =>
flag && flag.value
? flag
: {
type: 'ATOM',
value: flag
}
);
break;
case 'UID':
value =
value && value.value
? value
: {
type: 'ATOM',
value: (value || '0').toString()
};
break;
case 'MODSEQ':
value = [].concat(
value && value.value
? value
: {
type: 'ATOM',
value: (value || '0').toString()
}
);
break;
}
response.attributes[1].push({
type: 'ATOM',
value: key
});
response.attributes[1].push(value);
});
}
}
return response;
}
setUser(user) {
this.user = this.session.user = user;
}
}
// Expose to the world
module.exports.IMAPConnection = IMAPConnection;