diff --git a/README.md b/README.md index 3edfa62b..402cb151 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,38 @@ Run the server npm start +## Create user + +Users can be created with HTTP requests + +### POST /user/create + +Arguments + + * **username** is an email address of the user + * **password** is the password for the user + +**Example** + +``` +curl -XPOST "http://localhost:8080/user/create" -H 'content-type: application/json' -d '{ + "username": "username@example.com", + "password": "secretpass" +}' +``` + +The response for successful operation should look like this: + +```json +{ + "success": true, + "id": "58bd6815dddb5ac5063d3590", + "username": "username@example.com" +} +``` + +After you have created an user you can use these credentials to log in to the IMAP server. Additionally the LMTP server starts accepting mail for this email address. + ## License Wild Duck Mail Agent is licensed under the [European Union Public License 1.1](http://ec.europa.eu/idabc/eupl.html). diff --git a/api.js b/api.js new file mode 100644 index 00000000..95724281 --- /dev/null +++ b/api.js @@ -0,0 +1,179 @@ +'use strict'; + +const config = require('config'); +const restify = require('restify'); +const log = require('npmlog'); +const mongodb = require('mongodb'); +const MongoClient = mongodb.MongoClient; +const Joi = require('joi'); +const bcrypt = require('bcryptjs'); +const punycode = require('punycode'); + +let database; + +const server = restify.createServer(); + +server.use(restify.bodyParser({ + maxBodySize: 0, + mapParams: true, + mapFiles: false, + overrideParams: false +})); + +server.post('/user/create', (req, res, next) => { + const schema = Joi.object().keys({ + username: Joi.string().email().required(), + password: Joi.string().min(3).max(100).required() + }); + + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true, + allowUnknown: true + }); + + if (result.error) { + res.json({ + error: result.error.message + }); + return next(); + } + + let username = normalizeAddress(result.value.username); + let password = result.value.password; + + database.collection('users').findOne({ + username + }, (err, user) => { + if (err) { + return res.json({ + error: 'MongoDB Error: ' + err.message, + username + }); + } + if (user) { + return res.json({ + error: 'This username already exists', + username + }); + } + + let hash = bcrypt.hashSync(password, 8); + database.collection('users').insertOne({ + username, + password: hash + }, (err, result) => { + if (err) { + return res.json({ + error: 'MongoDB Error: ' + err.message, + username + }); + } + + let uidValidity = Math.floor(Date.now() / 1000); + + database.collection('mailboxes').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, + path: 'Junk', + specialUse: '\\Junk', + uidValidity, + uidNext: 1, + modifyIndex: 0, + subscribed: true + }], { + w: 1, + ordered: false + }, err => { + if (err) { + return res.json({ + error: 'MongoDB Error: ' + err.message, + username + }); + } + + res.json({ + success: true, + id: result.insertedId, + username + }); + + return next(); + }); + }); + }); +}); + +function normalizeAddress(address, withNames) { + if (typeof address === 'string') { + address = { + address + }; + } + if (!address || !address.address) { + return ''; + } + let user = address.address.substr(0, address.address.lastIndexOf('@')); + let domain = address.address.substr(address.address.lastIndexOf('@') + 1); + let addr = user.trim() + '@' + punycode.toASCII(domain.toLowerCase().trim()); + + if (withNames) { + return { + name: address.name || '', + address: addr + }; + } + + return addr; +} + +module.exports = (imap, done) => { + MongoClient.connect(config.mongo, (err, mongo) => { + if (err) { + log.error('LMTP', 'Could not initialize MongoDB: %s', err.message); + return; + } + database = mongo; + + let started = false; + + server.on('error', err => { + if (!started) { + started = true; + return done(err); + } + log.error('API', err); + }); + + server.listen(config.api.port, config.api.host, () => { + if (started) { + return server.close(); + } + started = true; + log.info('API', 'Server listening on %s:%s', config.api.host || '0.0.0.0', config.api.port); + done(null, server); + }); + }); +}; diff --git a/config/default.js b/config/default.js index 1240ef2a..7cffb8a6 100644 --- a/config/default.js +++ b/config/default.js @@ -5,17 +5,20 @@ module.exports = { level: 'silly' }, - imap: { - port: 9993, - host: '127.0.0.1', - maxUnflaggedMessages: 10 - }, - mongo: 'mongodb://127.0.0.1:27017/wildduck', - mx: { - port: 2525, + imap: { + port: 9993, + host: '127.0.0.1' + }, + + lmtp: { + port: 2424, host: '0.0.0.0', - maxMB: 2 + maxMB: 5 + }, + + api: { + port: 8080 } }; diff --git a/imap.js b/imap.js new file mode 100644 index 00000000..aaf83f2f --- /dev/null +++ b/imap.js @@ -0,0 +1,1119 @@ +'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 fs = require('fs'); +const Indexer = require('./imap-core/lib/indexer/indexer'); + +// Setup server +let server = new IMAPServer({ + secure: true, + //key: config.imap.key ? fs.readFileSync(config.imap.key) : false, + //cert: config.imap.cert ? fs.readFileSync(config.imap.cert) : false, + id: { + name: 'test' + }, + logger: { + info: log.silly.bind(log, 'IMAP'), + debug: log.silly.bind(log, 'IMAP'), + error: log.error.bind(log, 'IMAP') + } +}); + +let database; + + +server.onAuth = function (login, session, callback) { + let username = (login.username || '').toString().replace(/\./g, '').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); + 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)); + } + 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 + }); + }); + } + + 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 = null; + 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); + } + 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 + }; + + if (!options.metadataOnly) { + //projection.raw = true; + 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: {} + }) + })); + + 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, + flags: true, + modseq: true + }; + + if (options.terms.includes('body') || options.terms.includes('text') || options.terms.includes('header')) { + projection.raw = 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 + }); + } + } + }); + } + + 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(); + } + + let match = session.matchSearchQuery(message, options.query); + 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'; + + // 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); + } + + // check if message with same Message-ID exists + database.collection('messages').findOne({ + mailbox: mailbox._id, + messageId + }, (err, message) => { + if (err) { + return callback(err); + } + + if (message) { + // message already exists, skip + return callback(null, true, { + uidValidity: mailbox.uidValidity, + uid: message.uid + }); + } + + // 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; + + let message = { + mailbox: mailbox._id, + uid: mailbox.uidNext, + internaldate: date && new Date(date) || new Date(), + raw, + flags, + unseen: !flags.includes('\\Seen'), + 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(); + + // 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); + }); + }); +}; diff --git a/lmtp.js b/lmtp.js new file mode 100644 index 00000000..eb813790 --- /dev/null +++ b/lmtp.js @@ -0,0 +1,192 @@ +'use strict'; + +const config = require('config'); +const log = require('npmlog'); +const SMTPServer = require('smtp-server').SMTPServer; +const mongodb = require('mongodb'); +const MongoClient = mongodb.MongoClient; +const crypto = require('crypto'); +const punycode = require('punycode'); + +let imapServer; +let database; + +const server = new SMTPServer({ + + // log to console + logger: { + info(...args) { + args.shift(); + log.info('LMTP', ...args); + }, + debug(...args) { + args.shift(); + log.silly('LMTP', ...args); + }, + error(...args) { + args.shift(); + log.error('LMTP', ...args); + } + }, + + name: false, + lmtp: true, + + // not required but nice-to-have + banner: 'Welcome to Wild Duck Mail Agent', + + // disable STARTTLS to allow authentication in clear text mode + disabledCommands: ['AUTH', 'STARTTLS'], + + // Accept messages up to 10 MB + size: config.lmtp.maxMB * 1024 * 1024, + + // Validate RCPT TO envelope address. Example allows all addresses that do not start with 'deny' + // If this method is not set, all addresses are allowed + onRcptTo(address, session, callback) { + let username = normalizeAddress(address.address); + + database.collection('users').findOne({ + username + }, (err, user) => { + if (err) { + log.error('LMTP', err); + return callback(new Error('Database error')); + } + if (!user) { + return callback(new Error('Unknown recipient')); + } + + if (!session.users) { + session.users = new Set(); + } + + session.users.add(username); + + callback(); + }); + }, + + // Handle message stream + onData(stream, session, callback) { + let chunks = []; + let chunklen = 0; + let hash = crypto.createHash('md5'); + stream.on('readable', () => { + let chunk; + while ((chunk = stream.read()) !== null) { + chunks.push(chunk); + chunklen += chunk.length; + hash.update(chunk); + } + }); + + stream.once('error', err => { + log.error('LMTP', err); + callback(new Error('Error reading from stream')); + }); + + stream.once('end', () => { + let err; + if (stream.sizeExceeded) { + err = new Error('Error: message exceeds fixed maximum message size ' + config.lmtp.maxMB + ' MB'); + err.responseCode = 552; + return callback(err); + } + + if (!session.users || !session.users.size) { + return callback(new Error('Nowhere to save the mail to')); + } + + let users = Array.from(session.users); + let stored = 0; + let storeNext = () => { + if (stored >= users.length) { + return callback(null, 'Message queued as ' + hash.digest('hex').toUpperCase()); + } + + let username = users[stored++]; + + // add Delivered-To + let header = Buffer.from('Delivered-To: ' + username + '\r\n'); + chunks.unshift(header); + chunklen += header.length; + + imapServer.addToMailbox(username, 'INBOX', { + source: 'LMTP', + from: normalizeAddress(session.envelope.mailFrom && session.envelope.mailFrom.address || ''), + to: session.envelope.rcptTo.map(item => normalizeAddress(item.address)), + origin: session.remoteAddress, + originhost: session.clientHostname, + transhost: session.hostNameAppearsAs, + transtype: session.transmissionType, + time: Date.now() + }, false, [], Buffer.concat(chunks, chunklen), err => { + // remove Delivered-To + chunks.shift(); + chunklen -= header.length; + + if (err) { + log.error('LMTP', err); + } + + storeNext(); + }); + }; + + storeNext(); + }); + } +}); + +function normalizeAddress(address, withNames) { + if (typeof address === 'string') { + address = { + address + }; + } + if (!address || !address.address) { + return ''; + } + let user = address.address.substr(0, address.address.lastIndexOf('@')); + let domain = address.address.substr(address.address.lastIndexOf('@') + 1); + let addr = user.trim() + '@' + punycode.toASCII(domain.toLowerCase().trim()); + + if (withNames) { + return { + name: address.name || '', + address: addr + }; + } + + return addr; +} + +module.exports = (imap, done) => { + MongoClient.connect(config.mongo, (err, mongo) => { + if (err) { + log.error('LMTP', 'Could not initialize MongoDB: %s', err.message); + return; + } + database = mongo; + imapServer = imap; + + let started = false; + + server.on('error', err => { + if (!started) { + started = true; + return done(err); + } + log.error('LMTP', err); + }); + + server.listen(config.lmtp.port, config.lmtp.host, () => { + if (started) { + return server.close(); + } + started = true; + done(null, server); + }); + }); +}; diff --git a/package.json b/package.json index 43431e57..5696e8fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wildduck", - "version": "1.0.0", + "version": "1.0.1", "description": "IMAP server built with Node.js and MongoDB", "main": "index.js", "scripts": { @@ -20,19 +20,21 @@ }, "dependencies": { "addressparser": "^1.0.1", - "clone": "^1.0.2", - "libbase64": "^0.1.0", - "nodemailer-fetch": "^1.6.0", - "utf7": "^1.0.2", "bcryptjs": "^2.4.3", + "clone": "^1.0.2", "config": "^1.25.1", + "joi": "^10.2.2", + "libbase64": "^0.1.0", "mailparser": "^2.0.2", "mongodb": "^2.2.24", + "nodemailer-fetch": "^1.6.0", "npmlog": "^4.0.2", "redis": "^2.6.5", + "restify": "^4.3.0", "smtp-server": "^2.0.2", - "uuid": "^3.0.1", - "toml": "^2.3.2" + "toml": "^2.3.2", + "utf7": "^1.0.2", + "uuid": "^3.0.1" }, "repository": { "type": "git", diff --git a/server.js b/server.js index 49c46106..ef49421f 100644 --- a/server.js +++ b/server.js @@ -1,1148 +1,29 @@ '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 fs = require('fs'); -const Indexer = require('imap-core/lib/indexer/indexer'); -const simpleParser = require('mailparser').simpleParser; +let config = require('config'); +let log = require('npmlog'); +let imap = require('./imap'); +let lmtp = require('./lmtp'); +let api = require('./api'); log.level = config.log.level; -// Connect to this example server by running -// openssl s_client -crlf -connect localhost:9993 -// Username is "testuser" and password is "pass" - -// Setup server -let server = new IMAPServer({ - secure: true, - key: config.imap.key ? fs.readFileSync(config.imap.key) : false, - cert: config.imap.cert ? fs.readFileSync(config.imap.cert) : false, - id: { - name: 'test' - } -}); - -server.indexer = new Indexer(); - -MongoClient.connect(config.mongo, (err, database) => { +imap((err, imap) => { if (err) { - server.logger.error('Queue', 'Could not initialize MongoDB: %s', err.message); - return; + log.error('App', 'Failed to start IMAP server'); + return process.exit(1); } - - // setup notification system for updates - server.notifier = new ImapNotifier({ - database + lmtp(imap, err => { + if (err) { + log.error('App', 'Failed to start LMTP server'); + return process.exit(1); + } + api(imap, err => { + if (err) { + log.error('App', 'Failed to start API server'); + return process.exit(1); + } + log.info('App', 'All servers started, ready to process some mail'); + }); }); - - server.onAuth = function (login, session, callback) { - let username = (login.username || '').toString().replace(/\./g, '').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); - 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)); - } - 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 - }); - }); - } - - 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 = null; - 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); - } - 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 - }; - - if (!options.metadataOnly) { - //projection.raw = true; - 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: {} - }) - })); - - 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, - flags: true, - modseq: true - }; - - if (options.terms.includes('body') || options.terms.includes('text') || options.terms.includes('header')) { - projection.raw = 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 - }); - } - } - }); - } - - 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(); - } - - let match = session.matchSearchQuery(message, options.query); - 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"', 'API', path); - - simpleParser(raw, (err, parsed) => { - if (err) { - return callback(err); - } - - let attachments = parsed.attachments; - parsed.attachments = parsed.attachments && parsed.attachments.length ? true : false; - - 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'; - - // 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); - } - - // check if message with same Message-ID exists - database.collection('messages').findOne({ - mailbox: mailbox._id, - messageId - }, (err, message) => { - if (err) { - return callback(err); - } - - if (message) { - // message already exists, skip - return callback(null, true, { - uidValidity: mailbox.uidValidity, - uid: message.uid - }); - } - - // 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; - - let message = { - mailbox: mailbox._id, - uid: mailbox.uidNext, - internaldate: date && new Date(date) || new Date(), - raw, - flags, - unseen: !flags.includes('\\Seen'), - meta, - modseq: 0, - mimeTree, - envelope, - bodystructure, - attachments, - parsed, - 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; - - let cursor = database.collection('messages').find({ - mailbox: mailbox._id, - flags: { - $ne: '\\Flagged' - } - }).project({ - _id: true, - uid: true, - flags: true - }).sort([ - ['uid', -1] - ]).skip(config.imap.maxUnflaggedMessages); - - let processNext = () => { - cursor.next((err, message) => { - if (err) { - return callback(err); - } - if (!message) { - return cursor.close(() => { - server.notifier.fire(username, path); - - return callback(null, true, { - uidValidity, - uid - }); - }); - } - - database.collection('messages').deleteOne({ - _id: message._id - }, err => { - if (err) { - return cursor.close(() => callback(err)); - } - server.notifier.addEntries(username, path, { - command: 'EXPUNGE', - uid: message.uid, - message: message._id - }, processNext); - }); - }); - }; - - processNext(); - }); - }); - }); - }); - }); - }); - }; - - // start listening - server.listen(config.imap.port, config.imap.host); });