mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-01-27 10:18:25 +08:00
v1.9.0
This commit is contained in:
parent
00e178e8d9
commit
61d2813be9
10 changed files with 77 additions and 411 deletions
83
README.md
83
README.md
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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"
|
|
@ -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 }))
|
||||
|
|
|
@ -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')) {
|
||||
|
|
|
@ -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,
|
||||
|
|
44
lib/tools.js
44
lib/tools.js
|
@ -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
16
lmtp.js
|
@ -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)
|
||||
});
|
||||
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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/' +
|
||||
|
|
Loading…
Reference in a new issue