mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-10-02 01:45:46 +08:00
Added login rate limiting. Updated flags in batches. Do not index too long header values
v1.0.11
This commit is contained in:
parent
66146e6680
commit
c9b9442de6
13 changed files with 329 additions and 300 deletions
10
README.md
10
README.md
|
@ -452,17 +452,13 @@ Response message includes the following fields
|
|||
- **headers** is an array that lists all headers of the message. A header is an object:
|
||||
|
||||
- **key** is the lowercase key of the header
|
||||
- **value** is the header value in unicode (all encoded values are decoded to utf-8)
|
||||
- **value** is the header value in unicode (all encoded values are decoded to utf-8). The value is capped at around 800 characters.
|
||||
|
||||
- **date** is the receive date (not header Date: field)
|
||||
|
||||
- **mailbox** is the id of the mailbox this messages belongs to
|
||||
|
||||
- **flags** is an array of IMAP flags for this message
|
||||
|
||||
- **text** is the plaintext version of the message (derived from html if not present in message source)
|
||||
|
||||
- **html** is the HTML version of the message (derived from plaintext if not present in message source)
|
||||
- **html** is the HTML version of the message (derived from plaintext if not present in message source). It is an array of strings, each array element corresponds to different MIME node and might have its own html header
|
||||
|
||||
- **attachments** is an array of attachment objects. Attachments can be shared between messages.
|
||||
|
||||
|
@ -513,7 +509,7 @@ The response for successful operation should look like this:
|
|||
"date": "2017-04-03T10:34:43.007Z",
|
||||
"flags": ["\\Seen"],
|
||||
"text": "Hello world!",
|
||||
"html": "<p>Hello world!</p>",
|
||||
"html": ["<p>Hello world!</p>"],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "58e2254289cccb742fd6c015",
|
||||
|
|
|
@ -29,13 +29,14 @@ module.exports = {
|
|||
imap: {
|
||||
port: 9993,
|
||||
host: '127.0.0.1',
|
||||
// If certificate path is not defined, use built-in self-signed certs
|
||||
//key: '/path/to/server/key.pem'
|
||||
//cert: '/path/to/server/cert.pem'
|
||||
secure: true,
|
||||
// Max size for messages uploaded via APPEND
|
||||
maxMB: 5,
|
||||
// default quota storage in MB (can be overriden per user)
|
||||
maxStorage: 100
|
||||
maxStorage: 1000
|
||||
},
|
||||
|
||||
lmtp: {
|
||||
|
|
|
@ -1,98 +0,0 @@
|
|||
# imap-core
|
||||
|
||||
Node.js module to create custom IMAP servers.
|
||||
|
||||
This is something I have used mostly for client work as the lower level dependency for some specific applications that serve IMAP. I don't have any such clients right no so I published the code if anyone finds it useful. I removed all proprietary code developed for clients, the module is only about the lower level protocol usage and does not contain any actual server logic.
|
||||
|
||||
You can see an example implementation of an IMAP server from the [example script](examples/index.js). Most of the code is inherited from the Hoodiecrow test-IMAP server module but this module can be used for asynchronous data access while in Hoodiecrow everything was synchronous (storage was an in-memory object that was accessed and updated synchronously).
|
||||
|
||||
## Demo
|
||||
|
||||
Install dependencies
|
||||
|
||||
npm install
|
||||
|
||||
Run the example
|
||||
|
||||
node examples/index.js
|
||||
|
||||
Connect to the server on port 9993
|
||||
|
||||
openssl s_client -crlf -connect localhost:9993
|
||||
|
||||
Once connected use testuser:pass to log in
|
||||
|
||||
< * OK test ready
|
||||
> A LOGIN testuser pass
|
||||
< A OK testuser authenticated
|
||||
|
||||
## IMAP extension support
|
||||
|
||||
This project is going to support only selected extensions, that are minimally required.
|
||||
|
||||
## Sequence number handling
|
||||
|
||||
Sequence numbers are handled automatically, no need to do this in the application side – you only need to keep count of the incrementing UID's. All sequence number based operations are converted to use UID values instead.
|
||||
|
||||
## Handling large input
|
||||
|
||||
Unfortunately input handling for a single command is not stream based, so everything sent to the server is loaded into memory before being processed. Literal size can be limited though and in this case the server refuses to process literals bigger than configured size.
|
||||
|
||||
## SEARCH query
|
||||
|
||||
Search query is provided as a tree structure.
|
||||
|
||||
Possible SEARCH terms
|
||||
|
||||
- Array – a list of AND terms
|
||||
- **or** - in the form of `{key: 'or', value: [terms]}` where _terms_ is a list of OR terms
|
||||
- **not** - inverts another term. In the form of `{key: 'not', value: term}` where _term_ is the term that must be inverted
|
||||
- **flag** - describes a flag term. In the form of `{key: 'flag', value: 'term', exists: bool}` where _term_ is the flag name to look for and _bool_ indicates if the flag must be bresent (_true_) or missing (_false_)
|
||||
- **header** - describes a header value. Header key is a case insensitive exact match (eg. 'X-Foo' matches header 'X-Foo:' but not 'X-Fooz:'). Header value is a partial match. In the form of `{key: 'header', header: 'keyterm', value: 'valueterm'}` where _keyterm_ is the header key name and _valueterm_ is the value of the header. If value is empty then the query acts as boolean, if header key is present, then it matches, otherwise it does not match
|
||||
- **uid** - is a an array of UID values (numbers)
|
||||
- **all** - if present then indicates that all messages should match
|
||||
- **internaldate** - operates on the date the message was received. Date value is day based, so timezone and time should be discarded. In the form of `{key: 'internaldate', operator: 'op', value: 'val'}` where _op_ is one of '<', '=', '>=' and _val_ is a date string
|
||||
- **date** - operates on the date listed in the massage _Date:_ header. Date value is day based, so timezone and time should be discarded. In the form of `{key: 'date', operator: 'op', value: 'val'}` where _op_ is one of '<', '=', '>=' and _val_ is a date string
|
||||
- **body** - looks for a partial match in the message BODY (does not match header fields). In the form of `{key: 'body', value: 'term'}` where _term_ is the partial match to look for
|
||||
- **text** - looks for a partial match in the entire message, including the body and headers. In the form of `{key: 'text', value: 'term'}` where _term_ is the partial match to look for
|
||||
- **size** - matches message size. In the form of `{key: 'size', value: num, operator: 'op'}` where _op_ is one of '<', '=', '>' and _num_ is the size of the message
|
||||
- **charset** - sets the charset to be used in the text fields. Can be ignored as everything should be UTF-8 by default
|
||||
|
||||
## Currently implemented RFC3501 commands
|
||||
|
||||
- **APPEND**
|
||||
- **CAPABILITY**
|
||||
- **CHECK**
|
||||
- **CLOSE**
|
||||
- **COPY**
|
||||
- **CREATE**
|
||||
- **DELETE**
|
||||
- **EXPUNGE**
|
||||
- **FETCH**
|
||||
- **LIST**
|
||||
- **LOGIN**
|
||||
- **LOGOUT**
|
||||
- **LSUB**
|
||||
- **NOOP**
|
||||
- **RENAME**
|
||||
- **SEARCH**
|
||||
- **SELECT**
|
||||
- **STARTTLS**
|
||||
- **STATUS**
|
||||
- **STORE**
|
||||
- **SUBSCRIBE**
|
||||
- **UID COPY**
|
||||
- **UID STORE**
|
||||
- **UNSUBSCRIBE**
|
||||
|
||||
Extensions
|
||||
|
||||
- **Conditional STORE** rfc4551 and **ENABLE**
|
||||
- **Special-Use Mailboxes** rfc6154
|
||||
- **ID extension** rfc2971
|
||||
- **IDLE command** rfc2177
|
||||
- **NAMESPACE** rfc2342 (hard coded single user namespace)
|
||||
- **UNSELECT** rfc3691
|
||||
- **AUTHENTICATE PLAIN** and **SASL-IR**
|
||||
|
||||
Unlike the Hoodiecrow project you can not enable or disable extensions, everything is as it is.
|
|
@ -52,12 +52,14 @@ module.exports = {
|
|||
let readNext = () => {
|
||||
let chunk;
|
||||
while ((chunk = this.writeStream.read()) !== null) {
|
||||
if (this._deflate.write(chunk) === false) {
|
||||
if (this._deflate && this._deflate.write(chunk) === false) {
|
||||
return this._deflate.once('drain', readNext);
|
||||
}
|
||||
}
|
||||
// flush data to socket
|
||||
this._deflate.flush();
|
||||
if (this._deflate) {
|
||||
this._deflate.flush();
|
||||
}
|
||||
};
|
||||
this.writeStream.on('readable', readNext);
|
||||
|
||||
|
|
|
@ -41,6 +41,9 @@ module.exports = {
|
|||
}, this.session, (err, response) => {
|
||||
|
||||
if (err) {
|
||||
if (err.response) {
|
||||
return callback(null, err);
|
||||
}
|
||||
this._server.logger.info('[%s] Authentication error for %s using %s\n%s', this.id, username, 'LOGIN', err.message);
|
||||
return callback(err);
|
||||
}
|
||||
|
|
|
@ -102,6 +102,9 @@ module.exports = {
|
|||
flags[i] = flags[i].toLowerCase().replace(/^\\./, c => c.toUpperCase());
|
||||
}
|
||||
}
|
||||
if (flags[i].length > 255) {
|
||||
return callback(new Error('Too long value for a flag'));
|
||||
}
|
||||
}
|
||||
|
||||
// keep only unique flags
|
||||
|
|
|
@ -20,7 +20,7 @@ class IMAPComposer extends Transform {
|
|||
|
||||
if (typeof obj.pipe === 'function') {
|
||||
// pipe stream to socket and wait until it finishes before continuing
|
||||
this.connection._server.logger.debug('[%s] S: <pipe message stream to socket>', this.connection.id);
|
||||
this.connection._server.logger.debug('[%s] S: %s<pipe message stream to socket>', this.connection.id, obj.description || '');
|
||||
obj.pipe(this.connection[!this.connection.compression ? '_socket' : '_deflate'], {
|
||||
end: false
|
||||
});
|
||||
|
|
|
@ -252,7 +252,7 @@ class Indexer {
|
|||
let response = {
|
||||
attachments: [],
|
||||
text: '',
|
||||
html: ''
|
||||
html: []
|
||||
};
|
||||
|
||||
let htmlContent = [];
|
||||
|
@ -445,7 +445,7 @@ class Indexer {
|
|||
return match;
|
||||
});
|
||||
|
||||
response.html = htmlContent.filter(str => str.trim()).map(updateCidLinks).join('\n').trim();
|
||||
response.html = htmlContent.filter(str => str.trim()).map(updateCidLinks);
|
||||
response.text = textContent.filter(str => str.trim()).map(updateCidLinks).join('\n').trim();
|
||||
|
||||
callback(null, response);
|
||||
|
|
301
imap.js
301
imap.js
|
@ -1,6 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const log = require('npmlog');
|
||||
const util = require('util');
|
||||
const config = require('config');
|
||||
const IMAPServerModule = require('./imap-core');
|
||||
const IMAPServer = IMAPServerModule.IMAPServer;
|
||||
|
@ -11,11 +12,15 @@ const ObjectID = require('mongodb').ObjectID;
|
|||
const Indexer = require('./imap-core/lib/indexer/indexer');
|
||||
const imapTools = require('./imap-core/lib/imap-tools');
|
||||
const fs = require('fs');
|
||||
const rateLimiter = require('rolling-rate-limiter');
|
||||
const setupIndexes = require('./indexes.json');
|
||||
const MessageHandler = require('./lib/message-handler');
|
||||
const db = require('./lib/db');
|
||||
const packageData = require('./package.json');
|
||||
|
||||
// home many modifications to cache before writing
|
||||
const BULK_BATCH_SIZE = 150;
|
||||
|
||||
// Setup server
|
||||
const serverOptions = {
|
||||
secure: config.imap.secure,
|
||||
|
@ -52,25 +57,37 @@ let messageHandler;
|
|||
server.onAuth = function (login, session, callback) {
|
||||
let username = (login.username || '').toString().trim();
|
||||
|
||||
db.database.collection('users').findOne({
|
||||
username
|
||||
}, (err, user) => {
|
||||
// rate limit authentication attempts per username/source IP
|
||||
server.loginLimiter(username + ':' + session.remoteAddress, (err, timeLeft) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (!user) {
|
||||
return callback();
|
||||
if (timeLeft) {
|
||||
let err = new Error('Too many logins, try again later');
|
||||
err.response = 'NO';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(login.password, user.password)) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
user: {
|
||||
id: user._id,
|
||||
username
|
||||
db.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();
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
user: {
|
||||
id: user._id,
|
||||
username
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -352,9 +369,7 @@ server.onStatus = function (path, session, callback) {
|
|||
}
|
||||
db.database.collection('messages').find({
|
||||
mailbox: mailbox._id,
|
||||
flags: {
|
||||
$ne: '\\Seen'
|
||||
}
|
||||
seen: false
|
||||
}).count((err, unseen) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
|
@ -440,7 +455,7 @@ server.updateMailboxFlags = function (mailbox, update, callback) {
|
|||
}
|
||||
|
||||
// found some new flags not yet set for mailbox
|
||||
// FIXME: Should we send unsolicited FLAGS and PERMANENTFLAGS notifications?
|
||||
// FIXME: Should we send unsolicited FLAGS and PERMANENTFLAGS notifications? Probably not
|
||||
return db.database.collection('mailboxes').findOneAndUpdate({
|
||||
_id: mailbox._id
|
||||
}, {
|
||||
|
@ -466,12 +481,28 @@ server.onStore = function (path, update, session, callback) {
|
|||
return callback(null, 'NONEXISTENT');
|
||||
}
|
||||
|
||||
let cursor = db.database.collection('messages').find({
|
||||
let query = {
|
||||
mailbox: mailbox._id,
|
||||
uid: {
|
||||
$in: update.messages
|
||||
}
|
||||
}).project({
|
||||
};
|
||||
|
||||
if (update.unchangedSince) {
|
||||
query = {
|
||||
mailbox: mailbox._id,
|
||||
modseq: {
|
||||
$lte: update.unchangedSince
|
||||
},
|
||||
uid: {
|
||||
$in: update.messages
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let cursor = db.database.collection('messages').
|
||||
find(query).
|
||||
project({
|
||||
_id: true,
|
||||
uid: true,
|
||||
flags: true
|
||||
|
@ -479,20 +510,26 @@ server.onStore = function (path, update, session, callback) {
|
|||
['uid', 1]
|
||||
]);
|
||||
|
||||
let updateEntries = [];
|
||||
let notifyEntries = [];
|
||||
|
||||
let done = (...args) => {
|
||||
if (notifyEntries.length) {
|
||||
let entries = notifyEntries;
|
||||
notifyEntries = [];
|
||||
setImmediate(() => this.notifier.addEntries(session.user.id, path, entries, () => {
|
||||
this.notifier.fire(session.user.id, path);
|
||||
if (args[0]) { // first argument is an error
|
||||
return callback(...args);
|
||||
} else {
|
||||
server.updateMailboxFlags(mailbox, update, () => callback(...args));
|
||||
}
|
||||
}));
|
||||
return;
|
||||
if (updateEntries.length) {
|
||||
return db.database.collection('messages').bulkWrite(updateEntries, {
|
||||
ordered: false,
|
||||
w: 1
|
||||
}, () => {
|
||||
updateEntries = [];
|
||||
this.notifier.addEntries(session.user.id, path, notifyEntries, () => {
|
||||
notifyEntries = [];
|
||||
this.notifier.fire(session.user.id, path);
|
||||
if (args[0]) { // first argument is an error
|
||||
return callback(...args);
|
||||
} else {
|
||||
server.updateMailboxFlags(mailbox, update, () => callback(...args));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
this.notifier.fire(session.user.id, path);
|
||||
if (args[0]) { // first argument is an error
|
||||
|
@ -537,7 +574,6 @@ server.onStore = function (path, update, session, callback) {
|
|||
flagsupdate = {
|
||||
$set: {
|
||||
flags: message.flags,
|
||||
|
||||
seen: message.flags.includes('\\Seen'),
|
||||
flagged: message.flags.includes('\\Flagged'),
|
||||
deleted: message.flags.includes('\\Deleted')
|
||||
|
@ -645,31 +681,42 @@ server.onStore = function (path, update, session, callback) {
|
|||
}
|
||||
|
||||
if (updated) {
|
||||
db.database.collection('messages').findOneAndUpdate({
|
||||
_id: message._id
|
||||
}, flagsupdate, {}, err => {
|
||||
if (err) {
|
||||
return cursor.close(() => done(err));
|
||||
}
|
||||
|
||||
notifyEntries.push({
|
||||
command: 'FETCH',
|
||||
ignore: session.id,
|
||||
uid: message.uid,
|
||||
flags: message.flags,
|
||||
message: message._id
|
||||
});
|
||||
|
||||
if (notifyEntries.length > 100) {
|
||||
// emit notifications in batches of 100
|
||||
let entries = notifyEntries;
|
||||
notifyEntries = [];
|
||||
setImmediate(() => this.notifier.addEntries(session.user.id, path, entries, processNext));
|
||||
return;
|
||||
} else {
|
||||
setImmediate(() => processNext());
|
||||
updateEntries.push({
|
||||
updateOne: {
|
||||
filter: {
|
||||
_id: message._id
|
||||
},
|
||||
update: flagsupdate
|
||||
}
|
||||
});
|
||||
|
||||
notifyEntries.push({
|
||||
command: 'FETCH',
|
||||
ignore: session.id,
|
||||
uid: message.uid,
|
||||
flags: message.flags,
|
||||
message: message._id
|
||||
});
|
||||
|
||||
if (updateEntries.length >= BULK_BATCH_SIZE) {
|
||||
return db.database.collection('messages').bulkWrite(updateEntries, {
|
||||
ordered: false,
|
||||
w: 1
|
||||
}, err => {
|
||||
updateEntries = [];
|
||||
if (err) {
|
||||
return cursor.close(() => done(err));
|
||||
}
|
||||
|
||||
this.notifier.addEntries(session.user.id, path, notifyEntries, () => {
|
||||
notifyEntries = [];
|
||||
this.notifier.fire(session.user.id, path);
|
||||
processNext();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
processNext();
|
||||
}
|
||||
} else {
|
||||
processNext();
|
||||
}
|
||||
|
@ -696,7 +743,7 @@ server.onExpunge = function (path, update, session, callback) {
|
|||
|
||||
let cursor = db.database.collection('messages').find({
|
||||
mailbox: mailbox._id,
|
||||
flags: '\\Deleted'
|
||||
deleted: true
|
||||
}).project({
|
||||
_id: true,
|
||||
uid: true,
|
||||
|
@ -863,6 +910,8 @@ server.onCopy = function (path, update, session, callback) {
|
|||
|
||||
let sourceId = message._id;
|
||||
|
||||
// Copying is not done in bulk to minimize risk of going out of sync with incremental UIDs
|
||||
|
||||
sourceUid.unshift(message.uid);
|
||||
db.database.collection('mailboxes').findOneAndUpdate({
|
||||
_id: target._id
|
||||
|
@ -926,9 +975,7 @@ server.onCopy = function (path, update, session, callback) {
|
|||
});
|
||||
});
|
||||
};
|
||||
|
||||
processNext();
|
||||
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -1099,24 +1146,57 @@ server.onFetch = function (path, options, session, callback) {
|
|||
};
|
||||
|
||||
if (options.changedSince) {
|
||||
query.modseq = {
|
||||
$gt: options.changedSince
|
||||
query = {
|
||||
mailbox: mailbox._id,
|
||||
modseq: {
|
||||
$gt: options.changedSince
|
||||
},
|
||||
uid: {
|
||||
$in: options.messages
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let cursor = db.database.collection('messages').find(query).project(projection).sort([
|
||||
let isUpdated = false;
|
||||
let updateEntries = [];
|
||||
let notifyEntries = [];
|
||||
|
||||
let done = (...args) => {
|
||||
if (updateEntries.length) {
|
||||
return db.database.collection('messages').bulkWrite(updateEntries, {
|
||||
ordered: false,
|
||||
w: 1
|
||||
}, () => {
|
||||
updateEntries = [];
|
||||
this.notifier.addEntries(session.user.id, path, notifyEntries, () => {
|
||||
notifyEntries = [];
|
||||
this.notifier.fire(session.user.id, path);
|
||||
return callback(...args);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (isUpdated) {
|
||||
this.notifier.fire(session.user.id, path);
|
||||
}
|
||||
return callback(...args);
|
||||
};
|
||||
|
||||
let cursor = db.database.collection('messages').
|
||||
find(query).
|
||||
project(projection).
|
||||
sort([
|
||||
['uid', 1]
|
||||
]);
|
||||
|
||||
let rowCount = 0;
|
||||
let processNext = () => {
|
||||
cursor.next((err, message) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
return done(err);
|
||||
}
|
||||
if (!message) {
|
||||
return cursor.close(() => {
|
||||
this.notifier.fire(session.user.id, path);
|
||||
return callback(null, true);
|
||||
done(null, true);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1135,43 +1215,67 @@ server.onFetch = function (path, options, session, callback) {
|
|||
})
|
||||
}));
|
||||
|
||||
stream.description = util.format('* FETCH #%s uid=%s size=%sB ', ++rowCount, message.uid, message.size);
|
||||
|
||||
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));
|
||||
return cursor.close(() => done(err));
|
||||
});
|
||||
|
||||
// send formatted response to socket
|
||||
session.writeStream.write(stream, () => {
|
||||
|
||||
if (!options.markAsSeen || message.flags.includes('\\Seen')) {
|
||||
return processNext();
|
||||
}
|
||||
|
||||
if (!markAsSeen) {
|
||||
return processNext();
|
||||
}
|
||||
|
||||
this.logger.debug('[%s] UPDATE FLAGS for "%s"', session.id, message.uid);
|
||||
|
||||
db.database.collection('messages').findOneAndUpdate({
|
||||
_id: message._id
|
||||
}, {
|
||||
$addToSet: {
|
||||
flags: '\\Seen'
|
||||
isUpdated = true;
|
||||
|
||||
updateEntries.push({
|
||||
updateOne: {
|
||||
filter: {
|
||||
_id: message._id
|
||||
},
|
||||
update: {
|
||||
$addToSet: {
|
||||
flags: '\\Seen'
|
||||
},
|
||||
$set: {
|
||||
seen: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {}, err => {
|
||||
if (err) {
|
||||
return cursor.close(() => callback(err));
|
||||
}
|
||||
this.notifier.addEntries(session.user.id, path, {
|
||||
command: 'FETCH',
|
||||
ignore: session.id,
|
||||
uid: message.uid,
|
||||
flags: message.flags,
|
||||
message: message._id
|
||||
}, processNext);
|
||||
});
|
||||
|
||||
notifyEntries.push({
|
||||
command: 'FETCH',
|
||||
ignore: session.id,
|
||||
uid: message.uid,
|
||||
flags: message.flags,
|
||||
message: message._id
|
||||
});
|
||||
|
||||
if (updateEntries.length >= BULK_BATCH_SIZE) {
|
||||
return db.database.collection('messages').bulkWrite(updateEntries, {
|
||||
ordered: false,
|
||||
w: 1
|
||||
}, err => {
|
||||
updateEntries = [];
|
||||
if (err) {
|
||||
return cursor.close(() => done(err));
|
||||
}
|
||||
|
||||
this.notifier.addEntries(session.user.id, path, notifyEntries, () => {
|
||||
notifyEntries = [];
|
||||
this.notifier.fire(session.user.id, path);
|
||||
processNext();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
processNext();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -1469,7 +1573,8 @@ server.onSearch = function (path, options, session, callback) {
|
|||
});
|
||||
}
|
||||
|
||||
let cursor = db.database.collection('messages').find(query).
|
||||
let cursor = db.database.collection('messages').
|
||||
find(query).
|
||||
project({
|
||||
uid: true,
|
||||
modseq: true
|
||||
|
@ -1491,26 +1596,12 @@ server.onSearch = function (path, options, session, callback) {
|
|||
}));
|
||||
}
|
||||
|
||||
//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) {
|
||||
if (highestModseq < message.modseq) {
|
||||
highestModseq = message.modseq;
|
||||
}
|
||||
|
||||
//if (match) {
|
||||
uidList.push(message.uid);
|
||||
//}
|
||||
|
||||
processNext();
|
||||
//});
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -1590,6 +1681,14 @@ module.exports = done => {
|
|||
database: db.database
|
||||
});
|
||||
|
||||
server.loginLimiter = rateLimiter({
|
||||
redis: db.redis,
|
||||
namespace: 'UserLoginLimiter',
|
||||
// allow 100 login attempts per minute
|
||||
interval: 60 * 1000,
|
||||
maxInInterval: 100
|
||||
});
|
||||
|
||||
let started = false;
|
||||
|
||||
server.on('error', err => {
|
||||
|
|
24
indexes.json
24
indexes.json
|
@ -60,11 +60,17 @@
|
|||
"user": 1
|
||||
}
|
||||
}, {
|
||||
"name": "mailbox_uid_modseq",
|
||||
"name": "mailbox_ui",
|
||||
"key": {
|
||||
"mailbox": 1,
|
||||
"uid": 1,
|
||||
"modseq": 1
|
||||
"uid": 1
|
||||
}
|
||||
}, {
|
||||
"name": "mailbox_modseq_uid",
|
||||
"key": {
|
||||
"mailbox": 1,
|
||||
"modseq": 1,
|
||||
"uid": 1
|
||||
}
|
||||
}, {
|
||||
"name": "newer_first",
|
||||
|
@ -109,12 +115,6 @@
|
|||
"headers.key": 1,
|
||||
"headers.value": 1
|
||||
}
|
||||
}, {
|
||||
"name": "has_attachments",
|
||||
"key": {
|
||||
"mailbox": 1,
|
||||
"hasAttachments": 1
|
||||
}
|
||||
}, {
|
||||
"name": "fulltext",
|
||||
"key": {
|
||||
|
@ -139,6 +139,12 @@
|
|||
"mailbox": 1,
|
||||
"flagged": 1
|
||||
}
|
||||
}, {
|
||||
"name": "has_attachments",
|
||||
"key": {
|
||||
"mailbox": 1,
|
||||
"hasAttachments": 1
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
"collection": "attachment.files",
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
'use strict';
|
||||
|
||||
const config = require('config');
|
||||
const tools = require('./tools');
|
||||
const mongodb = require('mongodb');
|
||||
const redis = require('redis');
|
||||
const MongoClient = mongodb.MongoClient;
|
||||
|
||||
module.exports.database = false;
|
||||
module.exports.redis = false;
|
||||
|
||||
module.exports.connect = callback => {
|
||||
MongoClient.connect(config.mongo, (err, database) => {
|
||||
|
@ -12,6 +15,7 @@ module.exports.connect = callback => {
|
|||
return callback(err);
|
||||
}
|
||||
module.exports.database = database;
|
||||
module.exports.redis = redis.createClient(tools.redisConfig(config.redis));
|
||||
callback(null, database);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -8,8 +8,6 @@ const Indexer = require('../imap-core/lib/indexer/indexer');
|
|||
const ImapNotifier = require('./imap-notifier');
|
||||
const tools = require('./tools');
|
||||
const libmime = require('libmime');
|
||||
const createDOMPurify = require('dompurify');
|
||||
const jsdom = require('jsdom');
|
||||
|
||||
class MessageHandler {
|
||||
|
||||
|
@ -54,20 +52,6 @@ class MessageHandler {
|
|||
});
|
||||
}
|
||||
|
||||
cleanHtml(html) {
|
||||
let win = jsdom.jsdom('', {
|
||||
features: {
|
||||
FetchExternalResources: false, // disables resource loading over HTTP / filesystem
|
||||
ProcessExternalResources: false // do not execute JS within script blocks
|
||||
}
|
||||
}).defaultView;
|
||||
let domPurify = createDOMPurify(win);
|
||||
|
||||
return domPurify.sanitize(html, {
|
||||
ALLOW_UNKNOWN_PROTOCOLS: true // allow cid: images
|
||||
});
|
||||
}
|
||||
|
||||
add(options, callback) {
|
||||
|
||||
let id = new ObjectID();
|
||||
|
@ -91,6 +75,21 @@ class MessageHandler {
|
|||
} catch (E) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// trim long values as mongodb indexed fields can not be too long
|
||||
|
||||
if (Buffer.byteLength(key, 'utf-8') >= 255) {
|
||||
key = Buffer.from(key).slice(0, 255).toString();
|
||||
key = key.substr(0, key.length - 4);
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(value, 'utf-8') >= 880) {
|
||||
// value exceeds MongoDB max indexed value length
|
||||
value = Buffer.from(value).slice(0, 880).toString();
|
||||
// remove last 4 chars to be sure we do not have any incomplete unicode sequences
|
||||
value = value.substr(0, value.length - 4);
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
value
|
||||
|
@ -107,7 +106,75 @@ class MessageHandler {
|
|||
return callback(err);
|
||||
}
|
||||
|
||||
// Another server might be waiting for the lock like this.
|
||||
let internaldate = options.date && new Date(options.date) || new Date();
|
||||
let headerdate = mimeTree.parsedHeader.date && new Date(mimeTree.parsedHeader.date) || false;
|
||||
|
||||
let flags = [].concat(options.flags || []);
|
||||
|
||||
if (!headerdate || headerdate.toString() === 'Invalid Date') {
|
||||
headerdate = internaldate;
|
||||
}
|
||||
|
||||
// prepare message object
|
||||
let message = {
|
||||
_id: id,
|
||||
|
||||
internaldate,
|
||||
headerdate,
|
||||
flags,
|
||||
size,
|
||||
|
||||
meta: options.meta || {},
|
||||
|
||||
headers,
|
||||
mimeTree,
|
||||
envelope,
|
||||
bodystructure,
|
||||
messageId,
|
||||
|
||||
// use boolean for more common flags
|
||||
seen: flags.includes('\\Seen'),
|
||||
flagged: flags.includes('\\Flagged'),
|
||||
deleted: flags.includes('\\Deleted')
|
||||
};
|
||||
|
||||
if (maildata.attachments && maildata.attachments.length) {
|
||||
message.attachments = maildata.attachments;
|
||||
message.hasAttachments = true;
|
||||
} else {
|
||||
message.hasAttachments = false;
|
||||
}
|
||||
|
||||
let maxTextLength = 300 * 1024;
|
||||
|
||||
if (maildata.text) {
|
||||
message.text = maildata.text.replace(/\r\n/g, '\n').trim();
|
||||
message.text = message.text.length <= maxTextLength ? message.text : message.text.substr(0, maxTextLength);
|
||||
message.intro = message.text.replace(/\s+/g, ' ').trim();
|
||||
if (message.intro.length > 128) {
|
||||
message.intro = message.intro.substr(0, 128) + '…';
|
||||
}
|
||||
}
|
||||
|
||||
if (maildata.html && maildata.html.length) {
|
||||
let htmlSize = 0;
|
||||
message.html = maildata.html.map(html => {
|
||||
if (htmlSize >= maxTextLength || !html) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (htmlSize + Buffer.byteLength(html) <= maxTextLength) {
|
||||
htmlSize += Buffer.byteLength(html);
|
||||
return html;
|
||||
}
|
||||
|
||||
html = html.substr(0, htmlSize + Buffer.byteLength(html) - maxTextLength);
|
||||
htmlSize += Buffer.byteLength(html);
|
||||
return html;
|
||||
}).filter(html => html);
|
||||
}
|
||||
|
||||
// Another server might be waiting for the lock
|
||||
this.redlock.waitAcquireLock(mailbox._id.toString(), 30 * 1000, 10 * 1000, (err, lock) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
|
@ -166,64 +233,11 @@ class MessageHandler {
|
|||
|
||||
let mailbox = item.value;
|
||||
|
||||
let internaldate = options.date && new Date(options.date) || new Date();
|
||||
let headerdate = mimeTree.parsedHeader.date && new Date(mimeTree.parsedHeader.date) || false;
|
||||
|
||||
let flags = [].concat(options.flags || []);
|
||||
|
||||
if (!headerdate || headerdate.toString() === 'Invalid Date') {
|
||||
headerdate = internaldate;
|
||||
}
|
||||
|
||||
let message = {
|
||||
_id: id,
|
||||
|
||||
mailbox: mailbox._id,
|
||||
user: mailbox.user,
|
||||
|
||||
uid: mailbox.uidNext,
|
||||
modseq: mailbox.modifyIndex + 1,
|
||||
|
||||
internaldate,
|
||||
headerdate,
|
||||
flags,
|
||||
size,
|
||||
|
||||
meta: options.meta || {},
|
||||
|
||||
headers,
|
||||
mimeTree,
|
||||
envelope,
|
||||
bodystructure,
|
||||
messageId,
|
||||
|
||||
// use boolean for more common flags
|
||||
seen: flags.includes('\\Seen'),
|
||||
flagged: flags.includes('\\Flagged'),
|
||||
deleted: flags.includes('\\Deleted')
|
||||
};
|
||||
|
||||
if (maildata.attachments && maildata.attachments.length) {
|
||||
message.attachments = maildata.attachments;
|
||||
message.hasAttachments = true;
|
||||
} else {
|
||||
message.hasAttachments = false;
|
||||
}
|
||||
|
||||
// use mailparser to parse plaintext and html content
|
||||
let maxTextLength = 200 * 1024;
|
||||
if (maildata.text) {
|
||||
message.text = maildata.text.replace(/\r\n/g, '\n').trim();
|
||||
message.text = message.text.length <= maxTextLength ? message.text : message.text.substr(0, maxTextLength);
|
||||
message.intro = message.text.replace(/\s+/g, ' ').trim();
|
||||
if (message.intro.length > 256) {
|
||||
message.intro = message.intro.substr(0, 256) + '…';
|
||||
}
|
||||
}
|
||||
if (maildata.html) {
|
||||
message.html = this.cleanHtml(maildata.html.replace(/\r\n/g, '\n')).trim();
|
||||
message.html = message.html.length <= maxTextLength ? message.html : message.html.substr(0, maxTextLength);
|
||||
}
|
||||
// updated message object by setting mailbox specific values
|
||||
message.mailbox = mailbox._id;
|
||||
message.user = mailbox.user;
|
||||
message.uid = mailbox.uidNext;
|
||||
message.modseq = mailbox.modifyIndex + 1;
|
||||
|
||||
this.database.collection('messages').insertOne(message, err => {
|
||||
if (err) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "wildduck",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"description": "IMAP server built with Node.js and MongoDB",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
@ -22,12 +22,10 @@
|
|||
"bcryptjs": "^2.4.3",
|
||||
"clone": "^2.1.1",
|
||||
"config": "^1.25.1",
|
||||
"dompurify": "^0.8.5",
|
||||
"grid-fs": "^1.0.1",
|
||||
"html-to-text": "^3.2.0",
|
||||
"iconv-lite": "^0.4.15",
|
||||
"joi": "^10.3.4",
|
||||
"jsdom": "^9.12.0",
|
||||
"joi": "^10.4.1",
|
||||
"libbase64": "^0.1.0",
|
||||
"libmime": "^3.1.0",
|
||||
"libqp": "^1.1.0",
|
||||
|
@ -38,6 +36,7 @@
|
|||
"redfour": "^1.0.0",
|
||||
"redis": "^2.7.1",
|
||||
"restify": "^4.3.0",
|
||||
"rolling-rate-limiter": "^0.1.4",
|
||||
"smtp-server": "^2.0.3",
|
||||
"toml": "^2.3.2",
|
||||
"utf7": "^1.0.2",
|
||||
|
|
Loading…
Add table
Reference in a new issue