'use strict'; const log = require('npmlog'); const uuidV1 = require('uuid/v1'); const config = require('config'); const IMAPServerModule = require('./imap-core'); const IMAPServer = IMAPServerModule.IMAPServer; const mongodb = require('mongodb'); const MongoClient = mongodb.MongoClient; const ImapNotifier = require('./imap-notifier'); const imapHandler = IMAPServerModule.imapHandler; const bcrypt = require('bcryptjs'); const ObjectID = require('mongodb').ObjectID; const Indexer = require('./imap-core/lib/indexer/indexer'); const fs = require('fs'); // Setup server let serverOptions = { secure: config.imap.secure, id: { name: 'test' }, logger: { info: log.silly.bind(log, 'IMAP'), debug: log.silly.bind(log, 'IMAP'), error: log.error.bind(log, 'IMAP') } }; if (config.imap.key) { serverOptions.key = fs.readFileSync(config.imap.key); } if (config.imap.cert) { serverOptions.cert = fs.readFileSync(config.imap.cert); } let server = new IMAPServer(); let database; server.onAuth = function (login, session, callback) { let username = (login.username || '').toString().trim(); database.collection('users').findOne({ username }, (err, user) => { if (err) { return callback(err); } if (!user) { return callback(); } if (!bcrypt.compareSync(login.password, user.password)) { return callback(); } let ensureInitial = next => { let collection = database.collection('mailboxes'); collection.findOne({ username, path: 'INBOX' }, (err, mailbox) => { if (err) { return callback(err); } if (mailbox) { return next(); } let uidValidity = Math.floor(Date.now() / 1000); collection.insertMany([{ username, path: 'INBOX', uidValidity, uidNext: 1, modifyIndex: 0, subscribed: true }, { username, path: 'Sent Mail', specialUse: '\\Sent', uidValidity, uidNext: 1, modifyIndex: 0, subscribed: true }, { username, path: 'Trash', specialUse: '\\Trash', uidValidity, uidNext: 1, modifyIndex: 0, subscribed: true }, { username: login.username, path: 'Junk', specialUse: '\\Junk', uidValidity, uidNext: 1, modifyIndex: 0, subscribed: true }], { w: 1, ordered: false }, err => { if (err) { return callback(err); } return next(); }); }); }; ensureInitial(() => callback(null, { user: { username } })); }); }; // LIST "" "*" // Returns all folders, query is informational // folders is either an Array or a Map server.onList = function (query, session, callback) { this.logger.debug('[%s] LIST for "%s"', session.id, query); let username = session.user.username; database.collection('mailboxes').find({ username }).toArray(callback); }; // LSUB "" "*" // Returns all subscribed folders, query is informational // folders is either an Array or a Map server.onLsub = function (query, session, callback) { this.logger.debug('[%s] LSUB for "%s"', session.id, query); let username = session.user.username; database.collection('mailboxes').find({ username, subscribed: true }).toArray(callback); }; // SUBSCRIBE "path/to/mailbox" server.onSubscribe = function (path, session, callback) { this.logger.debug('[%s] SUBSCRIBE to "%s"', session.id, path); let username = session.user.username; database.collection('mailboxes').findOneAndUpdate({ username, path }, { $set: { subscribed: true } }, {}, (err, item) => { if (err) { return callback(err); } if (!item || !item.value) { // was not able to acquire a lock return callback(null, 'NONEXISTENT'); } callback(null, true); }); }; // UNSUBSCRIBE "path/to/mailbox" server.onUnsubscribe = function (path, session, callback) { this.logger.debug('[%s] UNSUBSCRIBE from "%s"', session.id, path); let username = session.user.username; database.collection('mailboxes').findOneAndUpdate({ username, path }, { $set: { subscribed: false } }, {}, (err, item) => { if (err) { return callback(err); } if (!item || !item.value) { // was not able to acquire a lock return callback(null, 'NONEXISTENT'); } callback(null, true); }); }; // CREATE "path/to/mailbox" server.onCreate = function (path, session, callback) { this.logger.debug('[%s] CREATE "%s"', session.id, path); let username = session.user.username; database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (mailbox) { return callback(null, 'ALREADYEXISTS'); } mailbox = { username, path, uidValidity: Math.floor(Date.now() / 1000), uidNext: 1, modifyIndex: 0, subscribed: true }; database.collection('mailboxes').insertOne(mailbox, err => { if (err) { return callback(err); } return callback(null, true); }); }); }; // RENAME "path/to/mailbox" "new/path" // NB! RENAME affects child and hierarchy mailboxes as well, this example does not do this server.onRename = function (path, newname, session, callback) { this.logger.debug('[%s] RENAME "%s" to "%s"', session.id, path, newname); let username = session.user.username; database.collection('mailboxes').findOne({ username, path: newname }, (err, mailbox) => { if (err) { return callback(err); } if (mailbox) { return callback(null, 'ALREADYEXISTS'); } database.collection('mailboxes').findOneAndUpdate({ username, path }, { $set: { path: newname } }, {}, (err, item) => { if (err) { return callback(err); } if (!item || !item.value) { // was not able to acquire a lock return callback(null, 'NONEXISTENT'); } callback(null, true); }); }); }; // DELETE "path/to/mailbox" server.onDelete = function (path, session, callback) { this.logger.debug('[%s] DELETE "%s"', session.id, path); let username = session.user.username; database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (!mailbox) { return callback(null, 'NONEXISTENT'); } if (mailbox.specialUse) { return callback(null, 'CANNOT'); } database.collection('mailboxes').deleteOne({ _id: mailbox._id }, err => { if (err) { return callback(err); } database.collection('journal').deleteMany({ mailbox: mailbox._id }, err => { if (err) { return callback(err); } database.collection('messages').deleteMany({ mailbox: mailbox._id }, err => { if (err) { return callback(err); } callback(null, true); }); }); }); }); }; // SELECT/EXAMINE server.onOpen = function (path, session, callback) { this.logger.debug('[%s] Opening "%s"', session.id, path); let username = session.user.username; database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (!mailbox) { return callback(null, 'NONEXISTENT'); } database.collection('messages').find({ mailbox: mailbox._id }).project({ uid: true }).sort([ ['uid', 1] ]).toArray((err, messages) => { if (err) { return callback(err); } mailbox.uidList = messages.map(message => message.uid); callback(null, mailbox); }); }); }; // STATUS (X Y X) server.onStatus = function (path, session, callback) { this.logger.debug('[%s] Requested status for "%s"', session.id, path); let username = session.user.username; database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (!mailbox) { return callback(null, 'NONEXISTENT'); } database.collection('messages').find({ mailbox: mailbox._id }).count((err, total) => { if (err) { return callback(err); } database.collection('messages').find({ mailbox: mailbox._id, unseen: true }).count((err, unseen) => { if (err) { return callback(err); } return callback(null, { messages: total, uidNext: mailbox.uidNext, uidValidity: mailbox.uidValidity, unseen }); }); }); }); }; // APPEND mailbox (flags) date message server.onAppend = function (path, flags, date, raw, session, callback) { this.logger.debug('[%s] Appending message to "%s"', session.id, path); let username = session.user.username; this.addToMailbox(username, path, { source: 'IMAP', user: username, time: Date.now() }, date, flags, raw, (err, status, data) => { if (err) { if (err.imapResponse) { return callback(null, err.imapResponse); } return callback(err); } callback(null, status, data); }); }; // STORE / UID STORE, updates flags for selected UIDs server.onUpdate = function (path, update, session, callback) { this.logger.debug('[%s] Updating messages in "%s"', session.id, path); let username = session.user.username; database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (!mailbox) { return callback(null, 'NONEXISTENT'); } let cursor = database.collection('messages').find({ mailbox: mailbox._id, uid: { $in: update.messages } }).project({ _id: true, uid: true, flags: true }).sort([ ['uid', 1] ]); let processNext = () => { cursor.next((err, message) => { if (err) { return callback(err); } if (!message) { return cursor.close(() => { this.notifier.fire(username, path); return callback(null, true); }); } let updated = false; switch (update.action) { case 'set': // check if update set matches current or is different if (message.flags.length !== update.value.length || update.value.filter(flag => message.flags.indexOf(flag) < 0).length) { updated = true; } // set flags message.flags = [].concat(update.value); break; case 'add': message.flags = message.flags.concat(update.value.filter(flag => { if (message.flags.indexOf(flag) < 0) { updated = true; return true; } return false; })); break; case 'remove': message.flags = message.flags.filter(flag => { if (update.value.indexOf(flag) < 0) { return true; } updated = true; return false; }); break; } if (!update.silent) { session.writeStream.write(session.formatResponse('FETCH', message.uid, { uid: update.isUid ? message.uid : false, flags: message.flags })); } if (updated) { database.collection('messages').findOneAndUpdate({ _id: message._id }, { $set: { flags: message.flags, unseen: !message.flags.includes('\\Seen') } }, {}, err => { if (err) { return cursor.close(() => callback(err)); } this.notifier.addEntries(username, path, { command: 'FETCH', ignore: session.id, uid: message.uid, flags: message.flags, message: message._id }, processNext); }); } else { processNext(); } }); }; processNext(); }); }; // EXPUNGE deletes all messages in selected mailbox marked with \Delete // EXPUNGE deletes all messages in selected mailbox marked with \Delete server.onExpunge = function (path, update, session, callback) { this.logger.debug('[%s] Deleting messages from "%s"', session.id, path); let username = session.user.username; database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (!mailbox) { return callback(null, 'NONEXISTENT'); } let cursor = database.collection('messages').find({ mailbox: mailbox._id, flags: '\\Deleted' }).project({ _id: true, uid: true }).sort([ ['uid', 1] ]); let processNext = () => { cursor.next((err, message) => { if (err) { return callback(err); } if (!message) { return cursor.close(() => { this.notifier.fire(username, path); // delete all attachments that do not have any active links to message objects database.collection('attachments.files').deleteMany({ 'metadata.messages': { $size: 0 } }, err => { if (err) { // ignore as we don't really care if we have orphans or not } return callback(null, true); }); }); } if (!update.silent) { session.writeStream.write(session.formatResponse('EXPUNGE', message.uid)); } database.collection('messages').deleteOne({ _id: message._id }, err => { if (err) { return cursor.close(() => callback(err)); } // remove link to message from attachments (if any exist) database.collection('attachments.files').updateMany({ 'metadata.messages': message._id }, { $pull: { 'metadata.messages': message._id } }, { multi: true, w: 1 }, err => { if (err) { // ignore as we don't really care if we have orphans or not } this.notifier.addEntries(username, path, { command: 'EXPUNGE', ignore: session.id, uid: message.uid, message: message._id }, processNext); }); }); }); }; processNext(); }); }; // COPY / UID COPY sequence mailbox server.onCopy = function (path, update, session, callback) { this.logger.debug('[%s] Copying messages from "%s" to "%s"', session.id, path, update.destination); let username = session.user.username; database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (!mailbox) { return callback(null, 'NONEXISTENT'); } database.collection('mailboxes').findOne({ username, path: update.destination }, (err, target) => { if (err) { return callback(err); } if (!target) { return callback(null, 'TRYCREATE'); } let cursor = database.collection('messages').find({ mailbox: mailbox._id, uid: { $in: update.messages } }); // no projection as we need to copy the entire message let sourceUid = []; let destinationUid = []; let processNext = () => { cursor.next((err, message) => { if (err) { return callback(err); } if (!message) { return cursor.close(() => { this.notifier.fire(username, target.path); return callback(null, true, { uidValidity: target.uidValidity, sourceUid, destinationUid }); }); } let sourceId = message._id; sourceUid.unshift(message.uid); database.collection('mailboxes').findOneAndUpdate({ _id: target._id }, { $inc: { uidNext: 1 } }, { uidNext: true }, (err, item) => { if (err) { return callback(err); } if (!item || !item.value) { // was not able to acquire a lock return callback(null, 'TRYCREATE'); } let uidNext = item.value.uidNext; destinationUid.unshift(uidNext); message._id = new ObjectID(); message.mailbox = target._id; message.uid = uidNext; if (!message.meta) { message.meta = {}; } message.meta.source = 'IMAPCOPY'; database.collection('messages').insertOne(message, err => { if (err) { return callback(err); } // remove link to message from attachments (if any exist) database.collection('attachments.files').updateMany({ 'metadata.messages': sourceId }, { $push: { 'metadata.messages': message._id } }, { multi: true, w: 1 }, err => { if (err) { // should we care about this error? } this.notifier.addEntries(username, target.path, { command: 'EXISTS', uid: message.uid, message: message._id }, processNext); }); }); }); }); }; processNext(); }); }); }; // sends results to socket server.onFetch = function (path, options, session, callback) { this.logger.debug('[%s] Requested FETCH for "%s"', session.id, path); let username = session.user.username; database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (!mailbox) { return callback(null, 'NONEXISTENT'); } let projection = { uid: true, modseq: true, internaldate: true, flags: true, envelope: true, bodystructure: true, size: true }; if (!options.metadataOnly) { projection.mimeTree = true; } let query = { mailbox: mailbox._id, uid: { $in: options.messages } }; if (options.changedSince) { query.modseq = { $gt: options.changedSince }; } let cursor = database.collection('messages').find(query).project(projection).sort([ ['uid', 1] ]); let processNext = () => { cursor.next((err, message) => { if (err) { return callback(err); } if (!message) { return cursor.close(() => { this.notifier.fire(username, path); return callback(null, true); }); } let stream = imapHandler.compileStream(session.formatResponse('FETCH', message.uid, { query: options.query, values: session.getQueryResponse(options.query, message, { logger: this.logger, fetchOptions: {}, database }) })); stream.on('error', err => { session.socket.write('INTERNAL ERROR\n'); session.socket.destroy(); // ended up in erroneus state, kill the connection to abort return cursor.close(() => callback(err)); }); // send formatted response to socket session.writeStream.write(stream, () => { if (!options.markAsSeen || message.flags.includes('\\Seen')) { return processNext(); } message.flags.unshift('\\Seen'); database.collection('messages').findOneAndUpdate({ _id: message._id }, { $set: { flags: message.flags, unseen: false } }, {}, err => { if (err) { return cursor.close(() => callback(err)); } this.notifier.addEntries(username, path, { command: 'FETCH', ignore: session.id, uid: message.uid, flags: message.flags, message: message._id }, processNext); }); }); }); }; processNext(); }); }; /** * Returns an array of matching UID values * * IMAP search can be quite complex, so we optimize here for most common queries to be handled * by MongoDB and then do the final filtering on the client side. This allows */ server.onSearch = function (path, options, session, callback) { let username = session.user.username; database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (!mailbox) { return callback(null, 'NONEXISTENT'); } // prepare query let query = { mailbox: mailbox._id }; let projection = { uid: true, internaldate: true, headerdate: true, flags: true, modseq: true }; if (options.terms.includes('body') || options.terms.includes('text') || options.terms.includes('header')) { projection.mimeTree = true; } if (!options.terms.includes('all')) { options.query.forEach(term => { switch (term.key) { case 'modseq': query.modseq = { $gte: term.value }; break; case 'uid': if (Array.isArray(term.value)) { if (!term.value.length) { // trying to find a message that does not exist return callback(null, { uidList: [], highestModseq: 0 }); } query.uid = { $in: term.value }; } else { query.uid = term.value; } break; case 'flag': { let entry = term.exists ? term.value : { $ne: term.value }; if (!query.$and) { query.$and = []; } query.$and.push({ flags: entry }); } break; case 'not': [].concat(term.value || []).forEach(term => { switch (term.key) { case 'flag': { let entry = !term.exists ? term.value : { $ne: term.value }; if (!query.$and) { query.$and = []; } query.$and.push({ flags: entry }); } break; } }); break; case 'internaldate': { let op = false; let value = new Date(term.value + ' GMT'); switch (term.operator) { case '<': op = '$lt'; break; case '<=': op = '$lte'; break; case '>': op = '$gt'; break; case '>=': op = '$gte'; break; } let entry = !op ? [{ $gte: value }, { $lt: new Date(value.getTime() + 24 * 3600 * 1000) }] : { [op]: value }; if (!query.$and) { query.$and = []; } query.$and.push({ internaldate: entry }); } break; case 'headerdate': { let op = false; let value = new Date(term.value + ' GMT'); switch (term.operator) { case '<': op = '$lt'; break; case '<=': op = '$lte'; break; case '>': op = '$gt'; break; case '>=': op = '$gte'; break; } let entry = !op ? [{ $gte: value }, { $lt: new Date(value.getTime() + 24 * 3600 * 1000) }] : { [op]: value }; if (!query.$and) { query.$and = []; } query.$and.push({ headerdate: entry }); } break; case 'size': { let op = '$eq'; let value = Number(term.value) || 0; switch (term.operator) { case '<': op = '$lt'; break; case '<=': op = '$lte'; break; case '>': op = '$gt'; break; case '>=': op = '$gte'; break; } let entry = { [op]: value }; if (!query.$and) { query.$and = []; } query.$and.push({ size: entry }); } break; } }); } let cursor = database.collection('messages').find(query). project(projection). sort([ ['uid', 1] ]); let highestModseq = 0; let uidList = []; let processNext = () => { cursor.next((err, message) => { if (err) { return callback(err); } if (!message) { return cursor.close(() => callback(null, { uidList, highestModseq })); } if (message.raw) { message.raw = message.raw.toString(); } session.matchSearchQuery(message, options.query, (err, match) => { if (err) { return cursor.close(() => callback(err)); } if (match && highestModseq < message.modseq) { highestModseq = message.modseq; } if (match) { uidList.push(message.uid); } processNext(); }); }); }; processNext(); }); }; server.addToMailbox = (username, path, meta, date, flags, raw, callback) => { server.logger.debug('[%s] Appending message to "%s" for "%s"', 'API', path, username); let mimeTree = server.indexer.parseMimeTree(raw); let envelope = server.indexer.getEnvelope(mimeTree); let bodystructure = server.indexer.getBodyStructure(mimeTree); let messageId = envelope[9] || uuidV1() + '@wildduck.email'; let id = new ObjectID(); // check if mailbox exists database.collection('mailboxes').findOne({ username, path }, (err, mailbox) => { if (err) { return callback(err); } if (!mailbox) { let err = new Error('Mailbox is missing'); err.imapResponse = 'TRYCREATE'; return callback(err); } // acquire new UID database.collection('mailboxes').findOneAndUpdate({ _id: mailbox._id }, { $inc: { uidNext: 1 } }, {}, (err, item) => { if (err) { return callback(err); } if (!item || !item.value) { // was not able to acquire a lock let err = new Error('Mailbox is missing'); err.imapResponse = 'TRYCREATE'; return callback(err); } let mailbox = item.value; // calculate size before removing large attachments from mime tree let size = server.indexer.getSize(mimeTree); // move large attachments to GridStore server.indexer.storeAttachments(id, mimeTree, 50 * 1024, err => { if (err) { return callback(err); } let internaldate = date && new Date(date) || new Date(); let headerdate = mimeTree.parsedHeader.date && new Date(mimeTree.parsedHeader.date) || false; if (!headerdate || headerdate.toString() === 'Invalid Date') { headerdate = internaldate; } let message = { _id: id, mailbox: mailbox._id, uid: mailbox.uidNext, internaldate, headerdate, flags, unseen: !flags.includes('\\Seen'), size, meta, modseq: 0, mimeTree, envelope, bodystructure, messageId }; database.collection('messages').insertOne(message, err => { if (err) { return callback(err); } server.notifier.addEntries(username, path, { command: 'EXISTS', uid: message.uid, message: message._id }, () => { let uidValidity = mailbox.uidValidity; let uid = message.uid; server.notifier.fire(username, path); return callback(null, true, { uidValidity, uid }); }); }); }); }); }); }; module.exports = done => { MongoClient.connect(config.mongo, (err, db) => { if (err) { server.logger.error('Queue', 'Could not initialize MongoDB: %s', err.message); return; } database = db; server.indexer = new Indexer({ database }); // setup notification system for updates server.notifier = new ImapNotifier({ database }); let started = false; server.on('error', err => { if (!started) { started = true; return done(err); } log.error('IMAP', err); }); // start listening server.listen(config.imap.port, config.imap.host, () => { if (started) { return server.close(); } started = true; done(null, server); }); }); };