diff --git a/lib/pop3-connection.js b/lib/pop3-connection.js index cd3463d1..7462fb8e 100644 --- a/lib/pop3-connection.js +++ b/lib/pop3-connection.js @@ -30,7 +30,24 @@ class POP3Connection extends EventEmitter { cid: this._id, host: this.remoteAddress }, 'Connection from %s', this.remoteAddress); - this._socket.write('+OK WDPop ready for requests from ' + this.remoteAddress + '\r\n'); + this.send('+OK WDPop ready for requests from ' + this.remoteAddress); + } + + 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 + }, 'S:', payload); + this._socket.write(payload + '\r\n'); } _setListeners() { @@ -67,7 +84,7 @@ class POP3Connection extends EventEmitter { tnx: 'close', cid: this._id, host: this.remoteAddress, - user: this.user + user: this.session.user && this.session.user.username }, 'Connection closed to %s', this.remoteAddress); this.emit('close'); @@ -87,7 +104,7 @@ class POP3Connection extends EventEmitter { this._server.logger.error({ err, tnx: 'error', - user: this.user + user: this.session.user && this.session.user.username }, '%s', err.message); this.emit('error', err); } @@ -98,6 +115,7 @@ class POP3Connection extends EventEmitter { * @event */ _onTimeout() { + this.send('-ERR Disconnected for inactivity'); this.close(); } @@ -148,6 +166,12 @@ class POP3Connection extends EventEmitter { let command = parts.shift().toUpperCase(); let args = parts.join(' '); + this._server.logger.debug({ + tnx: 'receive', + cid: this._id, + user: this.session.user && this.session.user.username + }, 'C:', (line || '').toString()); + if (typeof this['command_' + command] === 'function') { this['command_' + command](args, err => { if (err) { @@ -158,14 +182,14 @@ class POP3Connection extends EventEmitter { cid: this._id, host: this.remoteAddress }, 'Error running %s. %s', command, err.message); - this._socket.write('-ERR ' + err.message + '\r\n'); + this.send('-ERR ' + err.message); this.close(); } else { this.processQueue(); } }); } else { - this._socket.write('-ERR bad command\r\n'); + this.send('-ERR bad command'); this.close(); } } @@ -181,30 +205,29 @@ class POP3Connection extends EventEmitter { 'SASL PLAIN' ]; - this._socket.write('+OK Capability list follows\r\n' + - extensions.join('\r\n') + '\r\n.\r\n'); + this.send(['+OK Capability list follows'].concat(extensions)); next(); } command_USER(args, next) { if (this.session.state !== 'AUTHORIZATION') { - this._socket.write('-ERR Command not accepted\r\n'); + this.send('-ERR Command not accepted'); return next(); } if (!args) { - this._socket.write('-ERR USER who?\r\n'); + this.send('-ERR USER who?'); return next(); } this.session.user = args; - this._socket.write('+OK send PASS\r\n'); + this.send('+OK send PASS'); return next(); } command_PASS(args, next) { if (this.session.state !== 'AUTHORIZATION') { - this._socket.write('-ERR Command not accepted\r\n'); + this.send('-ERR Command not accepted'); return next(); } @@ -240,7 +263,7 @@ class POP3Connection extends EventEmitter { method: 'USER', user: username }, 'Authentication failed for %s using %s', username, 'USER'); - this._socket.write('-ERR [AUTH] ' + (response.message || 'Username and password not accepted.') + '\r\n'); + this.send('-ERR [AUTH] ' + (response.message || 'Username and password not accepted')); return next(); } @@ -251,15 +274,19 @@ class POP3Connection extends EventEmitter { user: username }, '%s authenticated using %s', username, 'USER'); this.session.user = response.user; - this.session.state = 'TRANSACTION'; - this._socket.write('+OK Welcome.\r\n'); - next(); + + this.openMailbox(err => { + if (err) { + return next(err); + } + next(); + }); }); } command_AUTH(args, next) { if (this.session.state !== 'AUTHORIZATION') { - this._socket.write('-ERR Command not accepted\r\n'); + this.send('-ERR Command not accepted'); return next(); } @@ -268,18 +295,18 @@ class POP3Connection extends EventEmitter { let plain = params.shift(); if (mechanism !== 'PLAIN') { - this._socket.write('-ERR unsupported SASL mechanism\r\n'); + this.send('-ERR unsupported SASL mechanism'); return next(); } if (params.length || !/^[a-zA-Z0-9+\/]+=+?$/.test(plain)) { - this._socket.write('-ERR malformed command\r\n'); + this.send('-ERR malformed command'); return next(); } let credentials = Buffer.from(plain, 'base64').toString().split('\x00'); if (credentials.length !== 3) { - this._socket.write('-ERR malformed command\r\n'); + this.send('-ERR malformed command'); return next(); } @@ -310,7 +337,7 @@ class POP3Connection extends EventEmitter { method: 'PLAIN', user: username }, 'Authentication failed for %s using %s', username, 'PLAIN'); - this._socket.write('-ERR [AUTH] ' + (response.message || 'Username and password not accepted.') + '\r\n'); + this.send('-ERR [AUTH] ' + (response.message || 'Username and password not accepted')); return next(); } @@ -321,18 +348,22 @@ class POP3Connection extends EventEmitter { user: username }, '%s authenticated using %s', username, 'PLAIN'); this.session.user = response.user; - this.session.state = 'TRANSACTION'; - this._socket.write('+OK Welcome.\r\n'); - next(); + + this.openMailbox(err => { + if (err) { + return next(err); + } + next(); + }); }); } // https://tools.ietf.org/html/rfc1939#page-9 command_NOOP(args, next) { if (this.session.state !== 'TRANSACTION') { - this._socket.write('-ERR Command not accepted\r\n'); + this.send('-ERR Command not accepted'); } else { - this._socket.write('+OK\r\n'); + this.send('+OK'); } return next(); } @@ -340,7 +371,7 @@ class POP3Connection extends EventEmitter { // https://tools.ietf.org/html/rfc1939#section-6 command_QUIT() { let finish = () => { - this._socket.write('+OK Bye\r\n'); + this.send('+OK Bye'); this.close(); }; @@ -351,6 +382,190 @@ class POP3Connection extends EventEmitter { // TODO: run pending actions finish(); } + + // 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'); + } + this.send('-ERR Future feature'); + return next(); + } + + // https://tools.ietf.org/html/rfc1939#page-11 + command_TOP(args, next) { + if (this.session.state !== 'TRANSACTION') { + this.send('-ERR Command not accepted'); + } + this.send('-ERR Future feature'); + return 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(); + }); + } } module.exports = POP3Connection; diff --git a/lib/pop3-server.js b/lib/pop3-server.js index 78c1ed40..8c690460 100644 --- a/lib/pop3-server.js +++ b/lib/pop3-server.js @@ -16,7 +16,7 @@ class POP3Server extends EventEmitter { this.options = options || {}; // apply shorthand handlers - ['onAuth'].forEach(handler => { + ['onAuth', 'onListMessages'].forEach(handler => { if (typeof this.options[handler] === 'function') { this[handler] = this.options[handler]; } @@ -150,6 +150,7 @@ class POP3Server extends EventEmitter { * Authentication handler. Override this * * @param {Object} auth Authentication options + * @param {Object} session Session object * @param {Function} callback Callback to run once the user is authenticated */ onAuth(auth, session, callback) { @@ -158,6 +159,21 @@ class POP3Server extends EventEmitter { }); } + /** + * Message listing handler. Override this + * + * @param {Object} session Session object + * @param {Function} callback Callback to run with message listing + */ + onListMessages(session, callback) { + // messages are objects {id: 'abc', size: 123} + return callback(null, { + messages: [], + count: 0, + size: 0 + }); + } + listen(...args) { this.server.listen(...args); } diff --git a/pop3.js b/pop3.js index f40af7dc..9448a197 100644 --- a/pop3.js +++ b/pop3.js @@ -7,6 +7,8 @@ const fs = require('fs'); const bcrypt = require('bcryptjs'); const db = require('./lib/db'); +const MAX_MESSAGES = 5000; + const serverOptions = { port: config.pop3.port, host: config.pop3.host, @@ -28,29 +30,67 @@ const serverOptions = { } }, - onAuth(auth, session, next) { + onAuth(auth, session, callback) { db.database.collection('users').findOne({ username: auth.username }, (err, user) => { if (err) { - return next(err); + return callback(err); } if (!user || !bcrypt.compareSync(auth.password, user.password)) { - return next(null, { + return callback(null, { message: 'Authentication failed' }); } - next(null, { + callback(null, { user: { id: user._id, username: user.username } }); }); - } + }, + onListMessages(session, callback) { + // only list messages in INBOX + db.database.collection('mailboxes').findOne({ + user: session.user.id, + path: 'INBOX' + }, (err, mailbox) => { + + if (err) { + return callback(err); + } + + if (!mailbox) { + return callback(new Error('Mailbox not found for user')); + } + + db.database.collection('messages').find({ + mailbox: mailbox._id + }).project({ + uid: true, + size: true + }).sort([ + ['uid', -1] + ]).limit(MAX_MESSAGES).toArray((err, messages) => { + if (err) { + return callback(err); + } + + return callback(null, { + messages: messages.map(message => ({ + id: message._id.toString(), + size: message.size + })), + count: messages.length, + size: messages.reduce((acc, message) => acc + message.size, 0) + }); + }); + }); + } }; if (config.pop3.key) {