wildduck/lib/pop3-connection.js

776 lines
23 KiB
JavaScript
Raw Normal View History

2017-04-08 02:29:14 +08:00
'use strict';
const crypto = require('crypto');
const EventEmitter = require('events');
2017-04-09 17:33:10 +08:00
const packageData = require('../package.json');
2017-04-10 04:21:36 +08:00
const DataStream = require('nodemailer/lib/smtp-connection/data-stream');
2017-04-08 02:29:14 +08:00
const SOCKET_TIMEOUT = 60 * 1000;
class POP3Connection extends EventEmitter {
constructor(server, socket) {
super();
this._server = server;
this._socket = socket;
this._closed = false;
this._closing = false;
this.remoteAddress = this._socket.remoteAddress;
this._id = crypto.randomBytes(9).toString('base64');
this.processing = false;
this.queue = [];
this._remainder = '';
}
init() {
this._setListeners();
this._resetSession();
this._server.logger.info({
tnx: 'connection',
cid: this._id,
host: this.remoteAddress
}, 'Connection from %s', this.remoteAddress);
this.send('+OK WDPop ready for requests from ' + this.remoteAddress);
}
2017-04-10 04:21:36 +08:00
write(payload) {
if (!this._socket || !this._socket.writable) {
return;
}
this._socket.write(payload);
}
send(payload) {
if (!this._socket || !this._socket.writable) {
return;
}
if (Array.isArray(payload)) {
payload = payload.join('\r\n') + '\r\n.';
}
this._server.logger.debug({
tnx: 'send',
cid: this._id,
host: this.remoteAddress
2017-04-13 03:04:57 +08:00
}, 'S:', (payload.length < 128 ? payload : payload.substr(0, 128) + '... +' + (payload.length - 128) + ' B').replace(/\r?\n/g,'\\n'));
2017-04-10 04:21:36 +08:00
this.write(payload + '\r\n');
2017-04-08 02:29:14 +08:00
}
_setListeners() {
this._socket.on('close', () => this._onClose());
this._socket.on('error', err => this._onError(err));
this._socket.setTimeout(this._server.options.socketTimeout || SOCKET_TIMEOUT, () => this._onTimeout());
this._socket.on('readable', () => {
if (this.processing) {
return;
}
this.processing = true;
this.read();
});
}
/**
* Fired when the socket is closed
* @event
*/
_onClose( /* hadError */ ) {
if (this._closed) {
return;
}
this.queue = [];
this.processing = false;
this._remainder = '';
this._closed = true;
this._closing = false;
this._server.logger.info({
tnx: 'close',
cid: this._id,
host: this.remoteAddress,
user: this.session.user && this.session.user.username
2017-04-08 02:29:14 +08:00
}, 'Connection closed to %s', this.remoteAddress);
this.emit('close');
}
/**
* Fired when an error occurs with the socket
*
* @event
* @param {Error} err Error object
*/
_onError(err) {
if (err.code === 'ECONNRESET' || err.code === 'EPIPE') {
return this.close(); // mark connection as 'closing'
}
this._server.logger.error({
err,
tnx: 'error',
user: this.session.user && this.session.user.username
2017-04-08 02:29:14 +08:00
}, '%s', err.message);
this.emit('error', err);
}
/**
* Fired when socket timeouts. Closes connection
*
* @event
*/
_onTimeout() {
this.send('-ERR Disconnected for inactivity');
2017-04-08 02:29:14 +08:00
this.close();
}
_resetSession() {
2017-04-08 03:38:52 +08:00
this.session = {
state: 'AUTHORIZATION'
};
2017-04-08 02:29:14 +08:00
}
close() {
if (!this._socket.destroyed && this._socket.writable) {
this._socket.end();
}
this._closing = true;
}
read() {
let chunk;
let data = this._remainder;
while ((chunk = this._socket.read()) !== null) {
data += chunk.toString('binary');
if (data.indexOf('\n') >= 0) {
let lines = data.split(/\r?\n/).map(line => Buffer.from(line, 'binary').toString());
this._remainder = lines.pop();
if (lines.length) {
if (this.queue.length) {
this.queue = this.queue.concat(lines);
} else {
this.queue = lines;
}
}
return this.processQueue();
}
}
this.processing = false;
}
processQueue() {
if (!this.queue.length) {
this.read(); // see if there's anything left to read
return;
}
let line = this.queue.shift().trim();
2017-04-10 04:40:16 +08:00
if (typeof this._nextHandler === 'function') {
let handler = this._nextHandler;
this._nextHandler = null;
this._server.logger.debug({
tnx: 'receive',
cid: this._id,
user: this.session.user && this.session.user.username
}, 'C: <%s bytes of continue data>', Buffer.byteLength(line));
return handler(line, err => {
if (err) {
this._server.logger.info({
err,
tnx: '+',
cid: this._id,
host: this.remoteAddress
}, 'Error processing continue data. %s', err.message);
this.send('-ERR ' + err.message);
this.close();
} else {
this.processQueue();
}
});
}
2017-04-08 02:29:14 +08:00
let parts = line.split(' ');
let command = parts.shift().toUpperCase();
let args = parts.join(' ');
2017-04-10 04:40:16 +08:00
let logLine = (line || '').toString();
if (/^(PASS|AUTH PLAIN)\s+[^\s]+/i.test(line)) {
logLine = logLine.replace(/[^\s]+$/, '*hidden*');
}
this._server.logger.debug({
tnx: 'receive',
cid: this._id,
user: this.session.user && this.session.user.username
2017-04-10 04:40:16 +08:00
}, 'C:', logLine);
2017-04-08 03:38:52 +08:00
if (typeof this['command_' + command] === 'function') {
this['command_' + command](args, err => {
if (err) {
this._server.logger.info({
err,
tnx: 'command',
command,
cid: this._id,
host: this.remoteAddress
}, 'Error running %s. %s', command, err.message);
this.send('-ERR ' + err.message);
2017-04-08 03:38:52 +08:00
this.close();
} else {
this.processQueue();
}
});
} else {
this.send('-ERR bad command');
2017-04-08 03:38:52 +08:00
this.close();
}
}
// https://tools.ietf.org/html/rfc2449#section-5
command_CAPA(args, next) {
let extensions = [
2017-04-09 17:33:10 +08:00
'CAPA',
2017-04-10 04:21:36 +08:00
'TOP',
2017-04-08 03:38:52 +08:00
'UIDL',
'USER',
'RESP-CODES',
// https://tools.ietf.org/html/rfc5034#section-6
2017-04-09 17:33:10 +08:00
'SASL PLAIN',
// https://tools.ietf.org/html/rfc2449#section-6.6
'PIPELINING',
'IMPLEMENTATION WildDuck-v' + packageData.version
2017-04-08 03:38:52 +08:00
];
this.send(['+OK Capability list follows'].concat(extensions));
2017-04-08 03:38:52 +08:00
next();
}
command_USER(args, next) {
if (this.session.state !== 'AUTHORIZATION') {
this.send('-ERR Command not accepted');
2017-04-08 03:38:52 +08:00
return next();
}
if (!args) {
this.send('-ERR USER who?');
2017-04-08 03:38:52 +08:00
return next();
}
this.session.user = args;
this.send('+OK send PASS');
2017-04-08 03:38:52 +08:00
return next();
}
command_PASS(args, next) {
if (this.session.state !== 'AUTHORIZATION') {
this.send('-ERR Command not accepted');
2017-04-08 03:38:52 +08:00
return next();
}
if (!this.session.user || !args) {
return next(new Error('malformed command'));
}
let username = this.session.user;
let password = args;
this.session.user = false;
this._server.onAuth({
method: 'USER',
username,
password
}, this.session, (err, response) => {
if (err) {
this._server.logger.info({
err,
tnx: 'autherror',
cid: this._id,
method: 'USER',
user: username
}, 'Authentication error for %s using %s. %s', username, 'USER', err.message);
return next(err);
}
if (!response.user) {
this._server.logger.info({
tnx: 'authfail',
cid: this._id,
method: 'USER',
user: username
}, 'Authentication failed for %s using %s', username, 'USER');
this.send('-ERR [AUTH] ' + (response.message || 'Username and password not accepted'));
2017-04-08 03:38:52 +08:00
return next();
}
this._server.logger.info({
tnx: 'auth',
cid: this._id,
method: 'USER',
user: username
}, '%s authenticated using %s', username, 'USER');
this.session.user = response.user;
this.openMailbox(err => {
if (err) {
return next(err);
}
next();
});
2017-04-08 02:29:14 +08:00
});
2017-04-08 03:38:52 +08:00
}
2017-04-08 02:29:14 +08:00
2017-04-08 03:38:52 +08:00
command_AUTH(args, next) {
if (this.session.state !== 'AUTHORIZATION') {
this.send('-ERR Command not accepted');
2017-04-08 03:38:52 +08:00
return next();
}
2017-04-10 04:21:36 +08:00
if (!args) {
this.send(['+OK', 'PLAIN']);
return next();
}
2017-04-08 03:38:52 +08:00
let params = args.split(/\s+/);
let mechanism = params.shift().toUpperCase();
if (mechanism !== 'PLAIN') {
this.send('-ERR unsupported SASL mechanism');
2017-04-08 03:38:52 +08:00
return next();
}
2017-04-10 04:40:16 +08:00
if (!params.length) {
this.send('+');
this._nextHandler = (args, next) => this.authPlain(args, next);
2017-04-08 03:38:52 +08:00
return next();
}
2017-04-10 04:40:16 +08:00
let plain = params.shift();
if (params.length) {
this.send('-ERR malformed command');
2017-04-08 03:38:52 +08:00
return next();
}
2017-04-10 04:40:16 +08:00
this.authPlain(plain, next);
2017-04-08 03:38:52 +08:00
}
// https://tools.ietf.org/html/rfc1939#page-9
command_NOOP(args, next) {
if (this.session.state !== 'TRANSACTION') {
this.send('-ERR Command not accepted');
2017-04-08 03:38:52 +08:00
} else {
this.send('+OK');
2017-04-08 03:38:52 +08:00
}
return next();
}
// https://tools.ietf.org/html/rfc1939#section-6
command_QUIT() {
let finish = () => {
2017-04-09 06:18:31 +08:00
this.session = false;
this.send('+OK Bye');
2017-04-08 03:38:52 +08:00
this.close();
};
if (this.session.state !== 'TRANSACTION') {
return finish();
}
this.session.state = 'UPDATE';
2017-04-09 06:18:31 +08:00
let deleted = this.session.listing.messages.filter(message => message.popped);
let seen = this.session.listing.messages.filter(message => !message.seen && message.fetched && !message.popped);
if (!deleted.length && !seen.length) {
return finish();
}
this._server.onUpdate({
deleted,
seen
}, this.session, finish);
2017-04-08 02:29:14 +08:00
}
// https://tools.ietf.org/html/rfc1939#page-6
command_STAT(args, next) {
if (this.session.state !== 'TRANSACTION') {
this.send('-ERR Command not accepted');
} else {
this.send('+OK ' + this.session.listing.count + ' ' + this.session.listing.size);
}
return next();
}
// https://tools.ietf.org/html/rfc1939#page-6
command_LIST(args, next) {
if (this.session.state !== 'TRANSACTION') {
this.send('-ERR Command not accepted');
return next();
}
let index = false;
if (args) {
index = Number(args);
}
if (args && (isNaN(index) || index <= 0)) {
return next(new Error('malformed command'));
}
if (args && index > this.session.listing.messages.length) {
this.send('-ERR no such message, only ' + this.session.listing.messages.length + ' messages in maildrop');
return next();
}
if (index) {
this.send('+OK ' + index + ' ' + this.session.listing.messages[index - 1].size);
} else {
this.send(
['+OK ' + this.session.listing.count + ' ' + this.session.listing.size]
.concat(
this.session.listing.messages
.filter(message => !message.popped)
.map((message, i) => (i + 1) + ' ' + message.size)
));
}
return next();
}
// https://tools.ietf.org/html/rfc1939#page-12
command_UIDL(args, next) {
if (this.session.state !== 'TRANSACTION') {
this.send('-ERR Command not accepted');
return next();
}
let index = false;
if (args) {
index = Number(args);
}
if (args && (isNaN(index) || index <= 0)) {
return next(new Error('malformed command'));
}
if (args && index > this.session.listing.messages.length) {
this.send('-ERR no such message, only ' + this.session.listing.messages.length + ' messages in maildrop');
return next();
}
if (index) {
this.send('+OK ' + index + ' ' + this.session.listing.messages[index - 1].id);
} else {
this.send(
['+OK']
.concat(
this.session.listing.messages
.filter(message => !message.popped)
.map((message, i) => (i + 1) + ' ' + message.id)
));
}
return next();
}
// https://tools.ietf.org/html/rfc1939#page-8
command_DELE(args, next) {
if (this.session.state !== 'TRANSACTION') {
this.send('-ERR Command not accepted');
return next();
}
let index = false;
if (args) {
index = Number(args);
}
if (!args || isNaN(index) || index <= 0) {
return next(new Error('malformed command'));
}
if (args && index > this.session.listing.messages.length) {
this.send('-ERR no such message, only ' + this.session.listing.messages.length + ' messages in maildrop');
return next();
}
let message = this.session.listing.messages[index - 1];
if (message.popped) {
this.send('-ERR message ' + index + ' already deleted');
return next();
}
message.popped = true;
this.session.listing.count--;
this.session.listing.size -= message.size;
this.send('+OK message ' + index + ' deleted');
return next();
}
// https://tools.ietf.org/html/rfc1939#page-9
command_RSET(args, next) {
if (this.session.state !== 'TRANSACTION') {
this.send('-ERR Command not accepted');
return next();
}
let count = 0;
let size = 0;
this.session.listing.messages.forEach(message => {
if (message.popped) {
message.popped = false;
count++;
size += message.size;
}
});
this.session.listing.count += count;
this.session.listing.size += size;
this.send('+OK maildrop has ' + this.session.listing.count + ' message' + (this.session.listing.count !== 1 ? 's' : '') + ' (' + this.session.listing.size + ' octets)');
return next();
}
// https://tools.ietf.org/html/rfc1939#page-8
command_RETR(args, next) {
if (this.session.state !== 'TRANSACTION') {
this.send('-ERR Command not accepted');
2017-04-09 06:18:31 +08:00
return next();
}
2017-04-09 06:18:31 +08:00
let index = false;
if (args) {
index = Number(args);
}
if (!args || isNaN(index) || index <= 0) {
return next(new Error('malformed command'));
}
if (args && index > this.session.listing.messages.length) {
this.send('-ERR no such message, only ' + this.session.listing.messages.length + ' messages in maildrop');
return next();
}
let message = this.session.listing.messages[index - 1];
2017-04-09 17:33:10 +08:00
if (message.popped) {
this.send('-ERR message ' + index + ' already deleted');
return next();
}
2017-04-09 06:18:31 +08:00
this._server.onFetchMessage(message.id, this.session, (err, stream) => {
if (err) {
return next(err);
}
if (!stream) {
return next(new Error('Can not find message'));
}
stream.once('error', err => next(err));
stream.once('end', () => {
2017-04-10 04:21:36 +08:00
// this.send('.'); // final dot is sent by DataStream
2017-04-09 06:18:31 +08:00
message.fetched = true;
return next();
});
this.send('+OK ' + message.size + ' octets');
2017-04-10 04:21:36 +08:00
stream.pipe(new DataStream()).pipe(this._socket, {
2017-04-09 06:18:31 +08:00
end: false
});
});
}
// https://tools.ietf.org/html/rfc1939#page-11
command_TOP(args, next) {
if (this.session.state !== 'TRANSACTION') {
this.send('-ERR Command not accepted');
2017-04-10 04:21:36 +08:00
return next();
}
2017-04-10 04:21:36 +08:00
let parts = args.split(' ');
let index = Number(parts[0]);
let lines = Number(parts[1]);
if (!args || parts.length !== 2 || isNaN(index) || index <= 0 || isNaN(lines) || lines < 0) {
return next(new Error('malformed command'));
}
if (args && index > this.session.listing.messages.length) {
this.send('-ERR no such message, only ' + this.session.listing.messages.length + ' messages in maildrop');
return next();
}
let message = this.session.listing.messages[index - 1];
if (message.popped) {
this.send('-ERR message ' + index + ' already deleted');
return next();
}
this._server.onFetchMessage(message.id, this.session, (err, stream) => {
if (err) {
return next(err);
}
if (!stream) {
return next(new Error('Can not find message'));
}
let data = '';
let headers = false;
let finished = false;
let linesSent = 0;
stream.on('readable', () => {
let chunk;
while ((chunk = stream.read()) !== null) {
if (!finished) {
data += chunk.toString('binary');
if (!headers) {
let match;
if ((match = data.match(/\r?\n\r?\n/))) {
headers = data.substr(0, match.index + match[0].length);
if (data.length > headers.length) {
data = data.substr(headers.length);
} else {
data = '';
}
2017-04-10 04:40:16 +08:00
this.write(Buffer.from(headers.replace(/^\./gm, '..'), 'binary'));
2017-04-10 04:21:36 +08:00
}
}
if (headers) {
if (linesSent >= lines) {
finished = true;
if (typeof stream.abort === 'function') {
stream.abort();
}
continue;
}
let match;
while (!finished && (match = data.match(/\r?\n/))) {
let line = data.substr(0, match.index + match[0].length);
linesSent++;
if (data.length > line.length) {
data = data.substr(line.length);
} else {
data = '';
}
2017-04-10 04:40:16 +08:00
this.write(Buffer.from(line.replace(/^\./gm, '..'), 'binary'));
2017-04-10 04:21:36 +08:00
if (linesSent >= lines) {
finished = true;
if (typeof stream.abort === 'function') {
stream.abort();
}
}
}
}
}
}
});
stream.once('error', err => next(err));
stream.once('end', () => {
this.send('.');
return next();
});
this.send('+OK message follows');
});
}
2017-04-10 04:40:16 +08:00
authPlain(plain, next) {
if (!/^[a-zA-Z0-9+\/]+=+?$/.test(plain)) {
this.send('-ERR malformed command');
return next();
}
let credentials = Buffer.from(plain, 'base64').toString().split('\x00');
if (credentials.length !== 3) {
this.send('-ERR malformed command');
return next();
}
let username = credentials[1] || credentials[0] || '';
let password = credentials[2] || '';
this._server.onAuth({
method: 'PLAIN',
username,
password
}, this.session, (err, response) => {
if (err) {
this._server.logger.info({
err,
tnx: 'autherror',
cid: this._id,
method: 'PLAIN',
user: username
}, 'Authentication error for %s using %s. %s', username, 'PLAIN', err.message);
return next(err);
}
if (!response.user) {
this._server.logger.info({
tnx: 'authfail',
cid: this._id,
method: 'PLAIN',
user: username
}, 'Authentication failed for %s using %s', username, 'PLAIN');
this.send('-ERR [AUTH] ' + (response.message || 'Username and password not accepted'));
return next();
}
this._server.logger.info({
tnx: 'auth',
cid: this._id,
method: 'PLAIN',
user: username
}, '%s authenticated using %s', username, 'PLAIN');
this.session.user = response.user;
this.openMailbox(err => {
if (err) {
return next(err);
}
next();
});
});
}
openMailbox(next) {
this._server.onListMessages(this.session, (err, listing) => {
if (err) {
this._server.logger.info({
err,
tnx: 'listerr',
cid: this._id,
user: this.session.user && this.session.user.username
}, 'Failed listing messages for %s. %s', this.session.user.username, err.message);
return next(err);
}
this.session.listing = listing;
this.session.state = 'TRANSACTION';
this.send('+OK maildrop has ' + listing.count + ' message' + (listing.count !== 1 ? 's' : '') + ' (' + listing.size + ' octets)');
return next();
});
}
2017-04-08 02:29:14 +08:00
}
module.exports = POP3Connection;