This commit is contained in:
Andris Reinman 2018-10-22 12:00:24 +03:00
parent 00e178e8d9
commit 61d2813be9
10 changed files with 77 additions and 411 deletions

View file

@ -11,14 +11,14 @@ WildDuck tries to follow Gmail in product design. If there's a decision to be ma
## Requirements
* _MongoDB_ to store all data
* _Redis_ for pubsub and counters
* _Node.js_ at least version 8.0.0
- _MongoDB_ to store all data
- _Redis_ for pubsub and counters
- _Node.js_ at least version 8.0.0
**Optional requirements**
* Redis Sentinel for automatic Redis failover
* Build tools to install optional dependencies that need compiling
- Redis Sentinel for automatic Redis failover
- Build tools to install optional dependencies that need compiling
WildDuck can be installed on any Node.js compatible platform.
@ -48,7 +48,7 @@ Tested on a 10$ DigitalOcean Ubuntu 16.04 instance.
![](https://cldup.com/TZoTfxPugm.png)
* Web interface at https://wildduck.email that uses WildDuck API
- Web interface at https://wildduck.email that uses WildDuck API
### Manual install
@ -157,9 +157,9 @@ specific, so (at least in theory) it could be replaced with any object store.
Here's a list of alternative email servers that also use a database for storing email messages:
* [DBMail](http://www.dbmail.org/) (MySQL, IMAP)
* [Archiveopteryx](http://archiveopteryx.org/) (PostgreSQL, IMAP)
* [ElasticInbox](http://www.elasticinbox.com/) (Cassandra, POP3)
- [DBMail](http://www.dbmail.org/) (MySQL, IMAP)
- [Archiveopteryx](http://archiveopteryx.org/) (PostgreSQL, IMAP)
- [ElasticInbox](http://www.elasticinbox.com/) (Cassandra, POP3)
### How does it work?
@ -182,25 +182,25 @@ and the user continues to see the old state.
WildDuck IMAP server supports the following IMAP standards:
* The entire **IMAP4rev1** suite with some minor differences from the spec. See below for [IMAP Protocol Differences](#imap-protocol-differences) for a complete
- The entire **IMAP4rev1** suite with some minor differences from the spec. See below for [IMAP Protocol Differences](#imap-protocol-differences) for a complete
list
* **IDLE** ([RFC2177](https://tools.ietf.org/html/rfc2177)) notfies about new and deleted messages and also about flag updates
* **CONDSTORE** ([RFC4551](https://tools.ietf.org/html/rfc4551)) and **ENABLE** ([RFC5161](https://tools.ietf.org/html/rfc5161)) supports most of the spec,
- **IDLE** ([RFC2177](https://tools.ietf.org/html/rfc2177)) notfies about new and deleted messages and also about flag updates
- **CONDSTORE** ([RFC4551](https://tools.ietf.org/html/rfc4551)) and **ENABLE** ([RFC5161](https://tools.ietf.org/html/rfc5161)) supports most of the spec,
except metadata stuff which is ignored
* **STARTTLS** ([RFC2595](https://tools.ietf.org/html/rfc2595))
* **NAMESPACE** ([RFC2342](https://tools.ietf.org/html/rfc2342)) minimal support, just lists the single user namespace with hierarchy separator
* **UNSELECT** ([RFC3691](https://tools.ietf.org/html/rfc3691))
* **UIDPLUS** ([RFC4315](https://tools.ietf.org/html/rfc4315))
* **SPECIAL-USE** ([RFC6154](https://tools.ietf.org/html/rfc6154))
* **ID** ([RFC2971](https://tools.ietf.org/html/rfc2971))
* **MOVE** ([RFC6851](https://tools.ietf.org/html/rfc6851))
* **AUTHENTICATE PLAIN** ([RFC4959](https://tools.ietf.org/html/rfc4959)) and **SASL-IR**
* **APPENDLIMIT** ([RFC7889](https://tools.ietf.org/html/rfc7889)) maximum global allowed message size is advertised in CAPABILITY listing
* **UTF8=ACCEPT** ([RFC6855](https://tools.ietf.org/html/rfc6855)) this also means that WildDuck natively supports unicode email usernames. For example
- **STARTTLS** ([RFC2595](https://tools.ietf.org/html/rfc2595))
- **NAMESPACE** ([RFC2342](https://tools.ietf.org/html/rfc2342)) minimal support, just lists the single user namespace with hierarchy separator
- **UNSELECT** ([RFC3691](https://tools.ietf.org/html/rfc3691))
- **UIDPLUS** ([RFC4315](https://tools.ietf.org/html/rfc4315))
- **SPECIAL-USE** ([RFC6154](https://tools.ietf.org/html/rfc6154))
- **ID** ([RFC2971](https://tools.ietf.org/html/rfc2971))
- **MOVE** ([RFC6851](https://tools.ietf.org/html/rfc6851))
- **AUTHENTICATE PLAIN** ([RFC4959](https://tools.ietf.org/html/rfc4959)) and **SASL-IR**
- **APPENDLIMIT** ([RFC7889](https://tools.ietf.org/html/rfc7889)) maximum global allowed message size is advertised in CAPABILITY listing
- **UTF8=ACCEPT** ([RFC6855](https://tools.ietf.org/html/rfc6855)) this also means that WildDuck natively supports unicode email usernames. For example
[андрис@уайлддак.орг](mailto:андрис@уайлддак.орг) is a valid email address that is hosted by a test instance of WildDuck
* **QUOTA** ([RFC2087](https://tools.ietf.org/html/rfc2087)) Quota size is global for an account, using a single quota root. Be aware that quota size does not
- **QUOTA** ([RFC2087](https://tools.ietf.org/html/rfc2087)) Quota size is global for an account, using a single quota root. Be aware that quota size does not
mean actual byte storage in disk, it is calculated as the sum of the [RFC822](https://tools.ietf.org/html/rfc822) sources of stored messages.
* **COMPRESS=DEFLATE** ([RFC4978](https://tools.ietf.org/html/rfc4978)) Compress traffic between the client and the server
- **COMPRESS=DEFLATE** ([RFC4978](https://tools.ietf.org/html/rfc4978)) Compress traffic between the client and the server
WildDuck more or less passes the [ImapTest](https://www.imapwiki.org/ImapTest/TestFeatures) Stress Testing run. Common errors that arise in the test are
unknown labels (WildDuck doesn't send unsolicited `FLAGS` updates even though it does send unsolicited `FETCH FLAGS` updates) and sometimes NO for `STORE`
@ -218,12 +218,12 @@ clients.
In addition to the required POP3 commands ([RFC1939](https://tools.ietf.org/html/rfc1939)) WildDuck supports the following extensions:
* **UIDL**
* **USER**
* **PASS**
* **SASL PLAIN**
* **PIPELINING**
* **TOP**
- **UIDL**
- **USER**
- **PASS**
- **SASL PLAIN**
- **PIPELINING**
- **TOP**
#### POP3 command behaviors
@ -248,7 +248,7 @@ If a messages is deleted by a client this message gets marked as Seen and moved
## Message filtering
WildDuck has built-in message filtering in LMTP server. This is somewhat similar to Sieve even though the filters are not scripts.
WildDuck has built-in message filtering. This is somewhat similar to Sieve even though the filters are not scripts.
Filters can be managed via the [WildDuck API](https://api.wildduck.email/#api-Filters).
@ -289,27 +289,24 @@ Use [WildDuck MTA](https://github.com/nodemailer/wildduck-mta) (which under the
[ZoneMTA-WildDuck](https://github.com/nodemailer/zonemta-wildduck) plugin).
This gives you an outbound SMTP server that uses WildDuck accounts for authentication. The plugin authenticates user credentials and also rewrites headers if
needed (if the header From: address does not match user address or aliases then it is rewritten). Additionally a copy of the sent message is uploaded to the
Sent Mail folder. Local delivery is done directly to WildDuck LMTP.
needed (if the header From: address does not match user address or aliases then it is rewritten).
## Inbound SMTP
Use [Haraka](http://haraka.github.io/) with [queue/lmtp](http://haraka.github.io/manual/plugins/queue/lmtp.html) plugin to deliver messages to WildDuck and
[haraka-plugins-wildduck](https://github.com/nodemailer/haraka-plugin-wildduck) to validate recipient addresses and quota usage against the WildDuck users
database.
Use [Haraka](http://haraka.github.io/) with [haraka-plugins-wildduck](https://github.com/nodemailer/haraka-plugin-wildduck) to validate recipient addresses and quota usage against the WildDuck users database and to store/filter messages.
## Future considerations
* Optimize FETCH queries to load only partial data for BODY subparts
* Parse incoming message into the mime tree as a stream. Currently the entire message is buffered in memory before being parsed.
* Maybe allow some kind of message manipulation through plugins
* WildDuck does not plan to be the most feature-rich IMAP client in the world. Most IMAP extensions are useless because there aren't too many clients that are
- Optimize FETCH queries to load only partial data for BODY subparts
- Parse incoming message into the mime tree as a stream. Currently the entire message is buffered in memory before being parsed.
- Maybe allow some kind of message manipulation through plugins
- WildDuck does not plan to be the most feature-rich IMAP client in the world. Most IMAP extensions are useless because there aren't too many clients that are
able to benefit from these extensions. There are a few extensions though that would make sense to be added to WildDuck:
* IMAP4 non-synchronizing literals, LITERAL- ([RFC7888](https://tools.ietf.org/html/rfc7888)). Synchronized literals are needed for APPEND to check mailbox
- IMAP4 non-synchronizing literals, LITERAL- ([RFC7888](https://tools.ietf.org/html/rfc7888)). Synchronized literals are needed for APPEND to check mailbox
quota, small values could go with the non-synchronizing version.
* LIST-STATUS ([RFC5819](https://tools.ietf.org/html/rfc5819))
* _What else?_ (definitely not NOTIFY nor QRESYNC)
- LIST-STATUS ([RFC5819](https://tools.ietf.org/html/rfc5819))
- _What else?_ (definitely not NOTIFY nor QRESYNC)
## Operating WildDuck

View file

@ -26,10 +26,6 @@ maxForwards=2000
# If set then reports errors to Bugsnag
bugsnagCode=""
# Header rules for routing spam
# This does not affect WildDuck plugin for Haraka
# @include "spamheaders.toml"
[dbs]
# @include "dbs.toml"

View file

@ -1,31 +0,0 @@
# key is a case insensitive header key
# value is a trimmed case-insensitive regex
# target is either special folder or exact name (normalized unicode string, not UTF7)
# special folders: ""INBOX", "\\Sent", "\\Trash", "\\Junk", "\\Drafts", "\\Archive"
[[spamHeader]]
# If this header exists and starts with "yes" then the message is treated as spam
# This is SpamAssassin header.
key="X-Spam-Status"
value="^yes"
target="\\Junk"
[[spamHeader]]
# If this header exists and starts with "yes" then the message is treated as spam
# This is Rspamd header. For the same with SpamAssassin use "X-Spam-Status"
key="X-Rspamd-Spam"
value="^yes"
target="\\Junk"
# Treat as spam if message has header with 6 or more plus signs:
# X-Rspamd-Bar: ++++++
[[spamHeader]]
key="X-Rspamd-Bar"
value="^\\+{6}"
target="\\Junk"
[[spamHeader]]
# If this header has a value, then it contains a virus, treat as spam
key="X-Haraka-Virus"
value="."
target="\\Junk"

View file

@ -35,15 +35,6 @@ function send() {
to: recipients
},
headers: {
/*
// uncomment to send the messge to Junk
'X-Rspamd-Bar': '/',
'X-Rspamd-Report': 'R_PARTS_DIFFER(0.5) MIME_GOOD(-0.1) R_DKIM_ALLOW(-0.2) R_SPF_ALLOW(-0.2)',
'X-Rspamd-Score': '22.6'
*/
},
from: 'Kärbes 🐧 <andris@kreata.ee>',
to: recipients
.map((rcpt, i) => ({ name: 'Recipient #' + (i + 1), address: rcpt }))

View file

@ -8,45 +8,11 @@ const Maildropper = require('./maildropper');
const tools = require('./tools');
const consts = require('./consts');
const defaultSpamHeaderKeys = [
{
key: 'X-Spam-Status',
value: '^yes',
target: '\\Junk'
},
{
key: 'X-Rspamd-Spam',
value: '^yes',
target: '\\Junk'
},
/*
{
key: 'X-Rspamd-Bar',
value: '^\\+{6}',
target: '\\Junk'
},
*/
{
key: 'X-Haraka-Virus',
value: '.',
target: '\\Junk'
}
];
const spamScoreHeader = 'X-Rspamd-Score';
const spamScoreValue = 5.1; // everything over this value is spam, under ham
class FilterHandler {
constructor(options) {
this.db = options.db;
this.messageHandler = options.messageHandler;
this.spamScoreValue = options.spamScoreValue || spamScoreValue;
this.spamChecks = options.spamChecks || tools.prepareSpamChecks(defaultSpamHeaderKeys);
this.spamHeaderKeys = options.spamHeaderKeys || this.spamChecks.map(check => check.key);
this.maildrop = new Maildropper({
db: this.db,
zone: options.sender.zone,
@ -164,8 +130,7 @@ class FilterHandler {
return this.messageHandler.prepareMessage(
{
mimeTree: options.mimeTree,
indexedHeaders: this.spamHeaderKeys
mimeTree: options.mimeTree
},
next
);
@ -173,8 +138,7 @@ class FilterHandler {
let raw = Buffer.concat(chunks, chunklen);
return this.messageHandler.prepareMessage(
{
raw,
indexedHeaders: this.spamHeaderKeys
raw
},
next
);
@ -239,20 +203,8 @@ class FilterHandler {
// ignore, as filtering is not so important
}
filters = (filters || []).filter(filter => !filter.disabled).concat(
this.spamChecks.map((check, i) => ({
id: 'SPAM#' + (i + 1),
query: {
headers: {
[check.key]: check.value
}
},
action: {
// only applies if any other filter does not already mark message as spam or ham
spam: true
}
}))
);
// remove disabled filters
filters = (filters || []).filter(filter => !filter.disabled);
let isEncrypted = false;
let forwardTargets = new Map();
@ -260,8 +212,6 @@ class FilterHandler {
let matchingFilters = [];
let filterActions = new Map();
let spamScore = parseFloat([].concat(prepared.mimeTree.parsedHeader[spamScoreHeader.toLowerCase()] || []).shift(), 10) || 0;
filters
// apply all filters to the message
.map(filter => checkFilter(filter, prepared, maildata))
@ -298,7 +248,28 @@ class FilterHandler {
isSpam = false;
filterActions.set('spam', false);
} else if (!filterActions.has('spam')) {
isSpam = (userData.spamLevel / 100) * this.spamScoreValue * 2 <= spamScore;
let spamLevel;
switch (meta.spamAction) {
case 'reject':
spamLevel = 25;
break;
case 'rewrite subject':
case 'soft reject':
spamLevel = 50;
break;
case 'greylist':
case 'add header':
spamLevel = 75;
break;
case 'no action':
default:
spamLevel = 100;
break;
}
isSpam = userData.spamLevel >= spamLevel;
}
if (isSpam && !filterActions.has('spam')) {
@ -328,8 +299,7 @@ class FilterHandler {
return this.messageHandler.prepareMessage(
{
raw: Buffer.concat([extraHeader, encrypted]),
indexedHeaders: this.spamHeaderKeys
raw: Buffer.concat([extraHeader, encrypted])
},
(err, preparedEncrypted) => {
if (err) {
@ -376,9 +346,9 @@ class FilterHandler {
(err, result) => {
if (err) {
// failed checks
log.error('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), err.message);
log.error('Filter', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), err.message);
} else if (!result.success) {
log.silly('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), 'Precondition failed');
log.silly('Filter', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), 'Precondition failed');
return done();
}
@ -464,7 +434,7 @@ class FilterHandler {
forwardMessage((err, id) => {
if (err) {
log.error(
'LMTP',
'Filter',
'%s FRWRDFAIL from=%s to=%s target=%s error=%s',
prepared.id.toString(),
sender,
@ -483,7 +453,7 @@ class FilterHandler {
});
outbound.push(id);
log.silly(
'LMTP',
'Filter',
'%s FRWRDOK id=%s from=%s to=%s target=%s',
prepared.id.toString(),
id,
@ -497,11 +467,11 @@ class FilterHandler {
sendAutoreply((err, id) => {
if (err) {
log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
log.error('Filter', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
} else if (id) {
filterResults.push({ autoreply: sender, 'autoreply-queue-id': id });
outbound.push(id);
log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
log.silly('Filter', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
}
if (filterActions.get('delete')) {

View file

@ -1160,9 +1160,8 @@ class MessageHandler {
});
}
generateIndexedHeaders(headersArray, options) {
generateIndexedHeaders(headersArray) {
// allow configuring extra header keys that are indexed
let indexedHeaders = options && options.indexedHeaders;
return (headersArray || [])
.map(line => {
line = Buffer.from(line, 'binary').toString();
@ -1172,7 +1171,7 @@ class MessageHandler {
.trim()
.toLowerCase();
if (!INDEXED_HEADERS.includes(key) && (!indexedHeaders || !indexedHeaders.includes(key))) {
if (!INDEXED_HEADERS.includes(key)) {
// do not index this header
return false;
}
@ -1260,7 +1259,7 @@ class MessageHandler {
let msgid = envelope[9] || '<' + uuidV1() + '@wildduck.email>';
let headers = this.generateIndexedHeaders(mimeTree.header, options);
let headers = this.generateIndexedHeaders(mimeTree.header);
let prepared = {
id,

View file

@ -356,49 +356,6 @@ function escapeRegexStr(string) {
return string.replace(RegExp('[' + specials.join('\\') + ']', 'g'), '\\$&');
}
function prepareSpamChecks(spamHeader) {
return (Array.isArray(spamHeader) ? spamHeader : [].concat(spamHeader || []))
.map(header => {
if (!header) {
return false;
}
// If only a single header key is specified, check if it matches Yes
if (typeof header === 'string') {
header = {
key: header,
value: '^yes',
target: '\\Junk'
};
}
let key = (header.key || '')
.toString()
.trim()
.toLowerCase();
let value = (header.value || '').toString().trim();
try {
if (value) {
value = new RegExp(value, 'i');
value.isRegex = true;
}
} catch (E) {
value = false;
//log.error('LMTP', 'Failed loading spam header rule %s. %s', JSON.stringify(header.value), E.message);
}
if (!key || !value) {
return false;
}
let target = (header.target || '').toString().trim() || 'INBOX';
return {
key,
value,
target
};
})
.filter(check => check);
}
function getRelayData(url) {
let urlparts = urllib.parse(url);
let targetMx = {
@ -478,7 +435,6 @@ module.exports = {
decodeAddresses,
getMailboxCounter,
getEmailTemplates,
prepareSpamChecks,
getRelayData,
isId,
uview,

16
lmtp.js
View file

@ -19,18 +19,8 @@ let messageHandler;
let userHandler;
let filterHandler;
let loggelf;
let spamChecks, spamHeaderKeys;
config.on('reload', () => {
spamChecks = tools.prepareSpamChecks(config.spamHeader);
spamHeaderKeys = spamChecks.map(check => check.key);
if (filterHandler) {
filterHandler.spamChecks = spamChecks;
filterHandler.spamHeaderKeys = spamHeaderKeys;
filterHandler.spamScoreValue = config.lmtp.spamScore;
}
log.info('LMTP', 'Configuration reloaded');
});
@ -241,9 +231,6 @@ module.exports = done => {
gelf.emit('gelf.log', message);
};
spamChecks = tools.prepareSpamChecks(config.spamHeader);
spamHeaderKeys = spamChecks.map(check => check.key);
messageHandler = new MessageHandler({
database: db.database,
users: db.users,
@ -265,9 +252,6 @@ module.exports = done => {
db,
sender: config.sender,
messageHandler,
spamHeaderKeys,
spamChecks,
spamScoreValue: config.lmtp.spamScore,
loggelf: message => loggelf(message)
});

View file

@ -1,6 +1,6 @@
{
"name": "wildduck",
"version": "1.8.1",
"version": "1.9.0",
"description": "IMAP/POP3 server built with Node.js and MongoDB",
"main": "server.js",
"scripts": {

View file

@ -330,202 +330,6 @@ describe('Send multiple messages', function() {
);
});
it('Send should send mail to spam', done => {
let recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com', 'user4@example.com', 'user5@example.com'];
let subject = 'Test ööö message [' + Date.now() + ']';
transporter.sendMail(
{
envelope: {
from: 'andris@kreata.ee',
to: recipients
},
headers: {
// set to Yes to send this message to Junk folder
'x-rspamd-spam': 'Yes'
},
from: 'Kärbes 🐧 <andris@kreata.ee>',
to: recipients.map((rcpt, i) => ({ name: 'User #' + (i + 1), address: rcpt })),
subject,
text: 'Hello world! Current time is ' + new Date().toString(),
html:
'<p>Hello world! Current time is <em>' +
new Date().toString() +
'</em> <img src="cid:note@example.com"/> <img src="http://www.neti.ee/img/neti-logo-2015-1.png"></p>',
attachments: [
// attachment as plaintext
{
filename: 'notes.txt',
content: 'Some notes about this e-mail',
contentType: 'text/plain' // optional, would be detected from the filename
},
// Small Binary Buffer attachment, should be kept with message
{
filename: 'image.png',
content: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD/' +
'//+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDeNGe4U' +
'g9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC',
'base64'
),
cid: 'note@example.com' // should be as unique as possible
},
// Large Binary Buffer attachment, should be kept separately
{
path: __dirname + '/../examples/swan.jpg',
filename: 'swän.jpg'
}
]
},
(err, info) => {
expect(err).to.not.exist;
expect(info.accepted).to.deep.equal(['user1@example.com', 'user2@example.com', 'user3@example.com', 'user4@example.com', 'user5@example.com']);
let getFirstMessage = (userId, callback) => {
request(URL + '/users/' + userId + '/mailboxes', { json: true }, (err, meta, response) => {
expect(err).to.not.exist;
expect(response.success).to.be.true;
let inbox = response.results.find(mbox => mbox.specialUse === '\\Junk');
request(URL + '/users/' + userId + '/mailboxes/' + inbox.id + '/messages', { json: true }, (err, meta, response) => {
expect(err).to.not.exist;
expect(response.success).to.be.true;
let message = response.results[0];
expect(message).to.exist;
request(URL + '/users/' + userId + '/mailboxes/' + inbox.id + '/messages/' + message.id, { json: true }, (err, meta, message) => {
expect(err).to.not.exist;
let processAttachments = next => {
let pos = 0;
let getAttachments = () => {
if (pos >= message.attachments.length) {
return next();
}
let attachment = message.attachments[pos++];
request(
URL +
'/users/' +
message.user +
'/mailboxes/' +
message.mailbox +
'/messages/' +
message.id +
'/attachments/' +
attachment.id,
{ encoding: null },
(err, meta, raw) => {
expect(err).to.not.exist;
attachment.raw = raw;
setImmediate(getAttachments);
}
);
};
setImmediate(getAttachments);
};
processAttachments(() => {
request(
URL + '/users/' + userId + '/mailboxes/' + inbox.id + '/messages/' + message.id + '/message.eml',
(err, meta, raw) => {
expect(err).to.not.exist;
message.raw = raw;
simpleParser(raw, (err, parsed) => {
expect(err).to.not.exist;
message.parsed = parsed;
callback(null, message);
});
}
);
});
});
});
});
};
let checkNormalUsers = next => {
let npos = 0;
let nusers = [1, 4, 5];
let checkUser = () => {
if (npos >= nusers.length) {
return next();
}
let user = nusers[npos++];
getFirstMessage(userIds[user - 1], (err, message) => {
expect(err).to.not.exist;
expect(message.subject).to.equal(subject);
expect(message.attachments.length).to.equal(3);
expect(message.parsed.attachments.length).to.equal(3);
for (let i = 0; i < message.attachments.length; i++) {
let hashA = crypto
.createHash('md5')
.update(message.attachments[i].raw)
.digest('hex');
let hashB = crypto
.createHash('md5')
.update(message.parsed.attachments[i].content)
.digest('hex');
expect(hashA).equal(hashB);
}
expect(message.parsed.to.value).deep.equal([
{ address: 'user1@example.com', name: 'User #1' },
{ address: 'user2@example.com', name: 'User #2' },
{ address: 'user3@example.com', name: 'User #3' },
{ address: 'user4@example.com', name: 'User #4' },
{ address: 'user5@example.com', name: 'User #5' }
]);
expect(message.parsed.headers.get('delivered-to').value[0].address).equal('user' + user + '@example.com');
setImmediate(checkUser);
});
};
setImmediate(checkUser);
};
let checkEncryptedUsers = next => {
let npos = 0;
let nusers = [2, 3];
let checkUser = () => {
if (npos >= nusers.length) {
return next();
}
let user = nusers[npos++];
getFirstMessage(userIds[user - 1], (err, message) => {
expect(err).to.not.exist;
expect(message.subject).to.equal(subject);
expect(message.parsed.to.value).deep.equal([
{ address: 'user1@example.com', name: 'User #1' },
{ address: 'user2@example.com', name: 'User #2' },
{ address: 'user3@example.com', name: 'User #3' },
{ address: 'user4@example.com', name: 'User #4' },
{ address: 'user5@example.com', name: 'User #5' }
]);
expect(message.parsed.headers.get('delivered-to').value[0].address).equal('user' + user + '@example.com');
expect(message.parsed.attachments.length).equal(2);
expect(message.parsed.attachments[0].contentType).equal('application/pgp-encrypted');
expect(message.parsed.attachments[0].content.toString()).equal('Version: 1\r\n');
expect(message.parsed.attachments[1].contentType).equal('application/octet-stream');
expect(message.parsed.attachments[1].filename).equal('encrypted.asc');
expect(message.parsed.attachments[1].size).gte(1000000);
setImmediate(checkUser);
});
};
setImmediate(checkUser);
};
checkNormalUsers(() => checkEncryptedUsers(() => done()));
}
);
});
it('should fetch messages from IMAP', done => {
let imagePng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD/' +