mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-12-27 02:10:52 +08:00
use filtering handler for filters
This commit is contained in:
parent
e6cb31b9a9
commit
dc6714d1d9
5 changed files with 666 additions and 397 deletions
464
lib/filter-handler.js
Normal file
464
lib/filter-handler.js
Normal file
|
@ -0,0 +1,464 @@
|
|||
'use strict';
|
||||
|
||||
const log = require('npmlog');
|
||||
const ObjectID = require('mongodb').ObjectID;
|
||||
const db = require('./db');
|
||||
const forward = require('./forward');
|
||||
const autoreply = require('./autoreply');
|
||||
|
||||
const defaultSpamHeaderKeys = [
|
||||
{
|
||||
key: 'X-Spam-Status',
|
||||
value: '^yes',
|
||||
target: '\\Junk'
|
||||
},
|
||||
|
||||
{
|
||||
key: 'X-Rspamd-Spam',
|
||||
value: '^yes',
|
||||
target: '\\Junk'
|
||||
},
|
||||
{
|
||||
key: 'X-Haraka-Virus',
|
||||
value: '.',
|
||||
target: '\\Junk'
|
||||
}
|
||||
];
|
||||
|
||||
class FilterHandler {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.users = options.users || options.database;
|
||||
this.redis = options.redis;
|
||||
this.messageHandler = options.messageHandler;
|
||||
this.spamChecks = options.spamChecks;
|
||||
this.spamHeaderKeys = options.spamHeaderKeys;
|
||||
}
|
||||
|
||||
getUserData(address, callback) {
|
||||
let query = {};
|
||||
if (!address) {
|
||||
return callback(null, false);
|
||||
}
|
||||
if (typeof address === 'object' && address._id) {
|
||||
return callback(null, address);
|
||||
}
|
||||
|
||||
let collection;
|
||||
|
||||
if (typeof address === 'object' && typeof address.getTimestamp === 'function') {
|
||||
query._id = address;
|
||||
collection = 'users';
|
||||
} else if (/^[a-f0-9]{24}$/.test(address)) {
|
||||
query._id = new ObjectID(address);
|
||||
collection = 'users';
|
||||
} else if (typeof address !== 'string') {
|
||||
return callback(null, false);
|
||||
} else if (address.indexOf('@') >= 0) {
|
||||
query.addrview = address.substr(0, address.indexOf('@')).replace(/\./g, '') + address.substr(address.indexOf('@'));
|
||||
collection = 'addresses';
|
||||
} else {
|
||||
query.unameview = address.replace(/\./g, '');
|
||||
collection = 'users';
|
||||
}
|
||||
|
||||
let fields = {
|
||||
name: true,
|
||||
forwards: true,
|
||||
forward: true,
|
||||
targetUrl: true,
|
||||
autoreply: true,
|
||||
encryptMessages: true,
|
||||
pubKey: true
|
||||
};
|
||||
|
||||
if (collection === 'users') {
|
||||
return db.users.collection('users').findOne(
|
||||
query,
|
||||
{
|
||||
fields
|
||||
},
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
return db.users.collection('addresses').findOne(query, (err, addressData) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (!addressData) {
|
||||
return callback(null, false);
|
||||
}
|
||||
return db.users.collection('users').findOne(
|
||||
{ _id: addressData.user },
|
||||
{
|
||||
fields
|
||||
},
|
||||
callback
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
process(options, callback) {
|
||||
this.getUserData(options.user || options.recipient, (err, userData) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (!userData) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
this.storeMessage(userData, options, callback);
|
||||
});
|
||||
}
|
||||
|
||||
storeMessage(userData, options, callback) {
|
||||
let sender = options.sender || '';
|
||||
let recipient = options.recipient || userData.address;
|
||||
|
||||
// create Delivered-To and Return-Path headers
|
||||
let extraHeader = Buffer.from(['Delivered-To: ' + recipient, 'Return-Path: <' + sender + '>'].join('\r\n') + '\r\n');
|
||||
|
||||
let chunks = options.chunks;
|
||||
let chunklen = options.chunklen;
|
||||
let raw = Buffer.concat([extraHeader].concat(chunks), chunklen + extraHeader.length);
|
||||
|
||||
let prepared = this.messageHandler.prepareMessage({
|
||||
raw,
|
||||
indexedHeaders: this.spamHeaderKeys
|
||||
});
|
||||
|
||||
console.log(require('util').inspect(prepared, false, 22));
|
||||
|
||||
let maildata = this.messageHandler.indexer.getMaildata(prepared.id, prepared.mimeTree);
|
||||
|
||||
// default flags are empty
|
||||
let flags = [];
|
||||
|
||||
// default mailbox target is INBOX
|
||||
let mailboxQueryKey = 'path';
|
||||
let mailboxQueryValue = 'INBOX';
|
||||
|
||||
db.database
|
||||
.collection('filters')
|
||||
.find({ user: userData._id })
|
||||
.sort({ _id: 1 })
|
||||
.toArray((err, filters) => {
|
||||
if (err) {
|
||||
// ignore, as filtering is not so important
|
||||
}
|
||||
|
||||
filters = (filters || []).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
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
let forwardTargets = new Set();
|
||||
let forwardTargetUrls = new Set();
|
||||
let matchingFilters = [];
|
||||
let filterActions = new Map();
|
||||
|
||||
filters
|
||||
// apply all filters to the message
|
||||
.map(filter => checkFilter(filter, prepared, maildata))
|
||||
// remove all unmatched filters
|
||||
.filter(filter => filter)
|
||||
// apply filter actions
|
||||
.forEach(filter => {
|
||||
matchingFilters.push(filter.id);
|
||||
|
||||
// apply matching filter
|
||||
if (!filterActions) {
|
||||
filterActions = filter.action;
|
||||
} else {
|
||||
Object.keys(filter.action).forEach(key => {
|
||||
if (key === 'forward') {
|
||||
forwardTargets.add(filter.action[key]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'targetUrl') {
|
||||
forwardTargetUrls.add(filter.action[key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// if a previous filter already has set a value then do not touch it
|
||||
if (!filterActions.has(key)) {
|
||||
filterActions.set(key, filter.action[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let forwardMessage = done => {
|
||||
if (userData.forward && !filterActions.get('delete')) {
|
||||
// forward to default recipient only if the message is not deleted
|
||||
forwardTargets.add(userData.forward);
|
||||
}
|
||||
|
||||
if (userData.targetUrl && !filterActions.get('delete')) {
|
||||
// forward to default URL only if the message is not deleted
|
||||
forwardTargetUrls.add(userData.targetUrl);
|
||||
}
|
||||
|
||||
// never forward messages marked as spam
|
||||
if ((!forwardTargets.size && !forwardTargetUrls.size) || filterActions.get('spam')) {
|
||||
return setImmediate(done);
|
||||
}
|
||||
|
||||
// check limiting counters
|
||||
this.messageHandler.counters.ttlcounter(
|
||||
'wdf:' + userData._id.toString(),
|
||||
forwardTargets.size + forwardTargetUrls.size,
|
||||
userData.forwards,
|
||||
false,
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
// failed checks
|
||||
log.error('LMTP', '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');
|
||||
return done();
|
||||
}
|
||||
|
||||
forward(
|
||||
{
|
||||
userData,
|
||||
sender,
|
||||
recipient,
|
||||
|
||||
forward: forwardTargets.size ? Array.from(forwardTargets) : false,
|
||||
targetUrl: forwardTargetUrls.size ? Array.from(forwardTargetUrls) : false,
|
||||
|
||||
chunks,
|
||||
chunklen
|
||||
},
|
||||
done
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
let sendAutoreply = done => {
|
||||
// never reply to messages marked as spam
|
||||
if (!sender || !userData.autoreply || filterActions.get('spam')) {
|
||||
return setImmediate(done);
|
||||
}
|
||||
|
||||
autoreply(
|
||||
{
|
||||
userData,
|
||||
sender,
|
||||
recipient,
|
||||
chunks,
|
||||
chunklen,
|
||||
messageHandler: this.messageHandler
|
||||
},
|
||||
done
|
||||
);
|
||||
};
|
||||
|
||||
forwardMessage((err, id) => {
|
||||
if (err) {
|
||||
log.error(
|
||||
'LMTP',
|
||||
'%s FRWRDFAIL from=%s to=%s target=%s error=%s',
|
||||
prepared.id.toString(),
|
||||
sender,
|
||||
recipient,
|
||||
Array.from(forwardTargets)
|
||||
.concat(forwardTargetUrls)
|
||||
.join(','),
|
||||
err.message
|
||||
);
|
||||
} else if (id) {
|
||||
log.silly(
|
||||
'LMTP',
|
||||
'%s FRWRDOK id=%s from=%s to=%s target=%s',
|
||||
prepared.id.toString(),
|
||||
id,
|
||||
sender,
|
||||
recipient,
|
||||
Array.from(forwardTargets)
|
||||
.concat(forwardTargetUrls)
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
sendAutoreply((err, id) => {
|
||||
if (err) {
|
||||
log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
|
||||
} else if (id) {
|
||||
log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
|
||||
}
|
||||
|
||||
if (filterActions.get('delete')) {
|
||||
// nothing to do with the message, just continue
|
||||
return callback(null, {
|
||||
userData,
|
||||
response: 'Message dropped by policy as ' + prepared.id.toString()
|
||||
});
|
||||
}
|
||||
|
||||
// apply filter results to the message
|
||||
filterActions.forEach((value, key) => {
|
||||
switch (key) {
|
||||
case 'spam':
|
||||
if (value > 0) {
|
||||
// positive value is spam
|
||||
mailboxQueryKey = 'specialUse';
|
||||
mailboxQueryValue = '\\Junk';
|
||||
}
|
||||
break;
|
||||
case 'seen':
|
||||
if (value) {
|
||||
flags.push('\\Seen');
|
||||
}
|
||||
break;
|
||||
case 'flag':
|
||||
if (value) {
|
||||
flags.push('\\Flagged');
|
||||
}
|
||||
break;
|
||||
case 'mailbox':
|
||||
if (value) {
|
||||
// positive value is spam
|
||||
mailboxQueryKey = 'mailbox';
|
||||
mailboxQueryValue = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
let messageOpts = {
|
||||
user: userData._id,
|
||||
[mailboxQueryKey]: mailboxQueryValue,
|
||||
|
||||
prepared,
|
||||
maildata,
|
||||
|
||||
meta: options.meta,
|
||||
|
||||
filters: matchingFilters,
|
||||
|
||||
date: false,
|
||||
flags,
|
||||
|
||||
// if similar message exists, then skip
|
||||
skipExisting: true
|
||||
};
|
||||
|
||||
this.messageHandler.encryptMessage(userData.encryptMessages ? userData.pubKey : false, raw, (err, encrypted) => {
|
||||
if (!err && encrypted) {
|
||||
messageOpts.prepared = this.messageHandler.prepareMessage({
|
||||
raw: encrypted,
|
||||
indexedHeaders: this.spamHeaderKeys
|
||||
});
|
||||
messageOpts.maildata = this.messageHandler.indexer.getMaildata(messageOpts.prepared.id, messageOpts.prepared.mimeTree);
|
||||
}
|
||||
|
||||
this.messageHandler.add(messageOpts, (err, inserted, info) =>
|
||||
// push to response list
|
||||
callback(null, {
|
||||
userData,
|
||||
response: err ? err : 'Message stored as ' + info.id.toString()
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkFilter(filter, prepared, maildata) {
|
||||
if (!filter || !filter.query) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let query = filter.query;
|
||||
|
||||
// prepare filter data
|
||||
let headerFilters = new Map();
|
||||
if (query.headers) {
|
||||
Object.keys(query.headers).forEach(key => {
|
||||
let value = query.headers[key];
|
||||
if (!value || !value.isRegex) {
|
||||
value = (query.headers[key] || '').toString().toLowerCase();
|
||||
}
|
||||
headerFilters.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// check headers
|
||||
if (headerFilters.size) {
|
||||
let headerMatches = new Set();
|
||||
for (let j = prepared.headers.length - 1; j >= 0; j--) {
|
||||
let header = prepared.headers[j];
|
||||
if (headerFilters.has(header.key)) {
|
||||
let check = headerFilters.get(header.key);
|
||||
if (check && check.isRegex && check.test(header.value)) {
|
||||
headerMatches.add(header.key);
|
||||
} else if (header.value.indexOf(headerFilters.get(header.key)) >= 0) {
|
||||
headerMatches.add(header.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (headerMatches.size < headerFilters.size) {
|
||||
// not enough matches
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof query.ha === 'boolean') {
|
||||
let hasAttachments = maildata.attachments && maildata.attachments.length;
|
||||
// false ha means no attachmens
|
||||
if (hasAttachments && !query.ha) {
|
||||
return false;
|
||||
}
|
||||
// true ha means attachmens must exist
|
||||
if (!hasAttachments && query.ha) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.size) {
|
||||
let messageSize = prepared.size;
|
||||
let filterSize = Math.abs(query.size);
|
||||
// negative value means "less than", positive means "more than"
|
||||
if (query.size < 0 && messageSize > filterSize) {
|
||||
return false;
|
||||
}
|
||||
if (query.size > 0 && messageSize < filterSize) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
query.text &&
|
||||
maildata.text
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.indexOf(query.text.toLowerCase()) < 0
|
||||
) {
|
||||
// message plaintext does not match the text field value
|
||||
return false;
|
||||
}
|
||||
|
||||
log.silly('Filter', 'Filter %s matched message %s', filter.id, prepared.id);
|
||||
|
||||
// we reached the end of the filter, so this means we have a match
|
||||
return filter;
|
||||
}
|
||||
|
||||
module.exports = FilterHandler;
|
420
lmtp.js
420
lmtp.js
|
@ -4,21 +4,27 @@
|
|||
|
||||
const config = require('wild-config');
|
||||
const log = require('npmlog');
|
||||
const ObjectID = require('mongodb').ObjectID;
|
||||
const SMTPServer = require('smtp-server').SMTPServer;
|
||||
const tools = require('./lib/tools');
|
||||
const MessageHandler = require('./lib/message-handler');
|
||||
const FilterHandler = require('./lib/filter-handler');
|
||||
const db = require('./lib/db');
|
||||
const forward = require('./lib/forward');
|
||||
const autoreply = require('./lib/autoreply');
|
||||
const certs = require('./lib/certs');
|
||||
|
||||
let messageHandler;
|
||||
let spamChecks = prepareSmapChecks(config.spamHeader);
|
||||
let spamHeaderKeys = spamChecks.map(check => check.key);
|
||||
let filterHandler;
|
||||
let spamChecks, spamHeaderKeys;
|
||||
|
||||
config.on('reload', () => {
|
||||
spamChecks = prepareSmapChecks(config.spamHeader);
|
||||
spamChecks = prepareSpamChecks(config.spamHeader);
|
||||
spamHeaderKeys = spamChecks.map(check => check.key);
|
||||
|
||||
if (filterHandler) {
|
||||
filterHandler.spamChecks = spamChecks;
|
||||
filterHandler.spamHeaderKeys = spamHeaderKeys;
|
||||
}
|
||||
|
||||
log.info('LMTP', 'Configuration reloaded');
|
||||
});
|
||||
|
||||
|
@ -132,6 +138,8 @@ const serverOptions = {
|
|||
let users = session.users;
|
||||
let stored = 0;
|
||||
|
||||
let transactionId = new ObjectID();
|
||||
|
||||
let storeNext = () => {
|
||||
if (stored >= users.length) {
|
||||
return callback(null, responses.map(r => r.response));
|
||||
|
@ -147,287 +155,35 @@ const serverOptions = {
|
|||
return storeNext();
|
||||
}
|
||||
|
||||
// create Delivered-To and Received headers
|
||||
let header = Buffer.from(
|
||||
['Delivered-To: ' + recipient, 'Return-Path: <' + (sender || '') + '>'].join('\r\n') + '\r\n'
|
||||
//+ 'Received: ' + generateReceivedHeader(session, queueId, os.hostname().toLowerCase(), recipient) + '\r\n'
|
||||
);
|
||||
|
||||
chunks.unshift(header);
|
||||
chunklen += header.length;
|
||||
|
||||
let raw = Buffer.concat(chunks, chunklen);
|
||||
|
||||
let meta = {
|
||||
source: 'LMTP',
|
||||
from: sender,
|
||||
to: recipient,
|
||||
origin: session.remoteAddress,
|
||||
originhost: session.clientHostname,
|
||||
transhost: session.hostNameAppearsAs,
|
||||
transtype: session.transmissionType,
|
||||
time: Date.now()
|
||||
};
|
||||
|
||||
let prepared = messageHandler.prepareMessage({
|
||||
raw,
|
||||
indexedHeaders: spamHeaderKeys
|
||||
});
|
||||
let maildata = messageHandler.indexer.getMaildata(prepared.id, prepared.mimeTree);
|
||||
|
||||
// default flags are empty
|
||||
let flags = [];
|
||||
|
||||
// default mailbox target is INBOX
|
||||
let mailboxQueryKey = 'path';
|
||||
let mailboxQueryValue = 'INBOX';
|
||||
|
||||
db.database
|
||||
.collection('filters')
|
||||
.find({ user: userData._id })
|
||||
.sort({ _id: 1 })
|
||||
.toArray((err, filters) => {
|
||||
if (err) {
|
||||
// ignore, as filtering is not so important
|
||||
filterHandler.process(
|
||||
{
|
||||
user: userData,
|
||||
sender,
|
||||
recipient,
|
||||
chunks,
|
||||
chunklen,
|
||||
meta: {
|
||||
transactionId,
|
||||
source: 'LMTP',
|
||||
from: sender,
|
||||
to: recipient,
|
||||
origin: session.remoteAddress,
|
||||
originhost: session.clientHostname,
|
||||
transhost: session.hostNameAppearsAs,
|
||||
transtype: session.transmissionType,
|
||||
time: Date.now()
|
||||
}
|
||||
|
||||
filters = (filters || []).concat(
|
||||
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
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
let forwardTargets = new Set();
|
||||
let forwardTargetUrls = new Set();
|
||||
let matchingFilters = [];
|
||||
let filterActions = new Map();
|
||||
|
||||
filters
|
||||
// apply all filters to the message
|
||||
.map(filter => checkFilter(filter, prepared, maildata))
|
||||
// remove all unmatched filters
|
||||
.filter(filter => filter)
|
||||
// apply filter actions
|
||||
.forEach(filter => {
|
||||
matchingFilters.push(filter.id);
|
||||
|
||||
// apply matching filter
|
||||
if (!filterActions) {
|
||||
filterActions = filter.action;
|
||||
} else {
|
||||
Object.keys(filter.action).forEach(key => {
|
||||
if (key === 'forward') {
|
||||
forwardTargets.add(filter.action[key]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'targetUrl') {
|
||||
forwardTargetUrls.add(filter.action[key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// if a previous filter already has set a value then do not touch it
|
||||
if (!filterActions.has(key)) {
|
||||
filterActions.set(key, filter.action[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let forwardMessage = done => {
|
||||
if (userData.forward && !filterActions.get('delete')) {
|
||||
// forward to default recipient only if the message is not deleted
|
||||
forwardTargets.add(userData.forward);
|
||||
}
|
||||
|
||||
if (userData.targetUrl && !filterActions.get('delete')) {
|
||||
// forward to default URL only if the message is not deleted
|
||||
forwardTargetUrls.add(userData.targetUrl);
|
||||
}
|
||||
|
||||
// never forward messages marked as spam
|
||||
if ((!forwardTargets.size && !forwardTargetUrls.size) || filterActions.get('spam')) {
|
||||
return setImmediate(done);
|
||||
}
|
||||
|
||||
// check limiting counters
|
||||
messageHandler.counters.ttlcounter(
|
||||
'wdf:' + userData._id.toString(),
|
||||
forwardTargets.size + forwardTargetUrls.size,
|
||||
userData.forwards,
|
||||
false,
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
// failed checks
|
||||
log.error('LMTP', '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');
|
||||
return done();
|
||||
}
|
||||
|
||||
forward(
|
||||
{
|
||||
userData,
|
||||
sender,
|
||||
recipient,
|
||||
|
||||
forward: forwardTargets.size ? Array.from(forwardTargets) : false,
|
||||
targetUrl: forwardTargetUrls.size ? Array.from(forwardTargetUrls) : false,
|
||||
|
||||
chunks
|
||||
},
|
||||
done
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
let sendAutoreply = done => {
|
||||
// never reply to messages marked as spam
|
||||
if (!sender || !userData.autoreply || filterActions.get('spam')) {
|
||||
return setImmediate(done);
|
||||
}
|
||||
|
||||
autoreply(
|
||||
{
|
||||
userData,
|
||||
sender,
|
||||
recipient,
|
||||
chunks,
|
||||
messageHandler
|
||||
},
|
||||
done
|
||||
);
|
||||
};
|
||||
|
||||
forwardMessage((err, id) => {
|
||||
if (err) {
|
||||
log.error(
|
||||
'LMTP',
|
||||
'%s FRWRDFAIL from=%s to=%s target=%s error=%s',
|
||||
prepared.id.toString(),
|
||||
sender,
|
||||
recipient,
|
||||
Array.from(forwardTargets)
|
||||
.concat(forwardTargetUrls)
|
||||
.join(','),
|
||||
err.message
|
||||
);
|
||||
} else if (id) {
|
||||
log.silly(
|
||||
'LMTP',
|
||||
'%s FRWRDOK id=%s from=%s to=%s target=%s',
|
||||
prepared.id.toString(),
|
||||
id,
|
||||
sender,
|
||||
recipient,
|
||||
Array.from(forwardTargets)
|
||||
.concat(forwardTargetUrls)
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
sendAutoreply((err, id) => {
|
||||
if (err) {
|
||||
log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
|
||||
} else if (id) {
|
||||
log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
|
||||
}
|
||||
|
||||
if (filterActions.get('delete')) {
|
||||
// nothing to do with the message, just continue
|
||||
responses.push({
|
||||
userData,
|
||||
response: 'Message dropped by policy as ' + prepared.id.toString()
|
||||
});
|
||||
prepared = false;
|
||||
maildata = false;
|
||||
return storeNext();
|
||||
}
|
||||
|
||||
// apply filter results to the message
|
||||
filterActions.forEach((value, key) => {
|
||||
switch (key) {
|
||||
case 'spam':
|
||||
if (value > 0) {
|
||||
// positive value is spam
|
||||
mailboxQueryKey = 'specialUse';
|
||||
mailboxQueryValue = '\\Junk';
|
||||
}
|
||||
break;
|
||||
case 'seen':
|
||||
if (value) {
|
||||
flags.push('\\Seen');
|
||||
}
|
||||
break;
|
||||
case 'flag':
|
||||
if (value) {
|
||||
flags.push('\\Flagged');
|
||||
}
|
||||
break;
|
||||
case 'mailbox':
|
||||
if (value) {
|
||||
// positive value is spam
|
||||
mailboxQueryKey = 'mailbox';
|
||||
mailboxQueryValue = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
let messageOpts = {
|
||||
user: userData._id,
|
||||
[mailboxQueryKey]: mailboxQueryValue,
|
||||
|
||||
prepared,
|
||||
maildata,
|
||||
|
||||
meta,
|
||||
|
||||
filters: matchingFilters,
|
||||
|
||||
date: false,
|
||||
flags,
|
||||
|
||||
// if similar message exists, then skip
|
||||
skipExisting: true
|
||||
};
|
||||
|
||||
messageHandler.encryptMessage(userData.encryptMessages ? userData.pubKey : false, raw, (err, encrypted) => {
|
||||
if (!err && encrypted) {
|
||||
messageOpts.prepared = messageHandler.prepareMessage({
|
||||
raw: encrypted,
|
||||
indexedHeaders: spamHeaderKeys
|
||||
});
|
||||
messageOpts.maildata = messageHandler.indexer.getMaildata(messageOpts.prepared.id, messageOpts.prepared.mimeTree);
|
||||
}
|
||||
|
||||
messageHandler.add(messageOpts, (err, inserted, info) => {
|
||||
// remove Delivered-To
|
||||
chunks.shift();
|
||||
chunklen -= header.length;
|
||||
|
||||
// push to response list
|
||||
responses.push({
|
||||
userData,
|
||||
response: err ? err : 'Message stored as ' + info.id.toString()
|
||||
});
|
||||
|
||||
storeNext();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
(err, response) => {
|
||||
if (err) {
|
||||
// ???
|
||||
}
|
||||
if (response) {
|
||||
responses.push(response);
|
||||
}
|
||||
setImmediate(storeNext);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
storeNext();
|
||||
|
@ -446,6 +202,9 @@ module.exports = done => {
|
|||
return setImmediate(() => done(null, false));
|
||||
}
|
||||
|
||||
spamChecks = prepareSpamChecks(config.spamHeader);
|
||||
spamHeaderKeys = spamChecks.map(check => check.key);
|
||||
|
||||
messageHandler = new MessageHandler({
|
||||
database: db.database,
|
||||
users: db.users,
|
||||
|
@ -454,6 +213,15 @@ module.exports = done => {
|
|||
attachments: config.attachments
|
||||
});
|
||||
|
||||
filterHandler = new FilterHandler({
|
||||
database: db.database,
|
||||
users: db.users,
|
||||
redis: db.redis,
|
||||
messageHandler,
|
||||
spamHeaderKeys,
|
||||
spamChecks
|
||||
});
|
||||
|
||||
let started = false;
|
||||
|
||||
server.on('error', err => {
|
||||
|
@ -473,87 +241,7 @@ module.exports = done => {
|
|||
});
|
||||
};
|
||||
|
||||
function checkFilter(filter, prepared, maildata) {
|
||||
if (!filter || !filter.query) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let query = filter.query;
|
||||
|
||||
// prepare filter data
|
||||
let headerFilters = new Map();
|
||||
if (query.headers) {
|
||||
Object.keys(query.headers).forEach(key => {
|
||||
let value = query.headers[key];
|
||||
if (!value || !value.isRegex) {
|
||||
value = (query.headers[key] || '').toString().toLowerCase();
|
||||
}
|
||||
headerFilters.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// check headers
|
||||
if (headerFilters.size) {
|
||||
let headerMatches = new Set();
|
||||
for (let j = prepared.headers.length - 1; j >= 0; j--) {
|
||||
let header = prepared.headers[j];
|
||||
if (headerFilters.has(header.key)) {
|
||||
let check = headerFilters.get(header.key);
|
||||
if (check && check.isRegex && check.test(header.value)) {
|
||||
headerMatches.add(header.key);
|
||||
} else if (header.value.indexOf(headerFilters.get(header.key)) >= 0) {
|
||||
headerMatches.add(header.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (headerMatches.size < headerFilters.size) {
|
||||
// not enough matches
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof query.ha === 'boolean') {
|
||||
let hasAttachments = maildata.attachments && maildata.attachments.length;
|
||||
// false ha means no attachmens
|
||||
if (hasAttachments && !query.ha) {
|
||||
return false;
|
||||
}
|
||||
// true ha means attachmens must exist
|
||||
if (!hasAttachments && query.ha) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.size) {
|
||||
let messageSize = prepared.size;
|
||||
let filterSize = Math.abs(query.size);
|
||||
// negative value means "less than", positive means "more than"
|
||||
if (query.size < 0 && messageSize > filterSize) {
|
||||
return false;
|
||||
}
|
||||
if (query.size > 0 && messageSize < filterSize) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
query.text &&
|
||||
maildata.text
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.indexOf(query.text.toLowerCase()) < 0
|
||||
) {
|
||||
// message plaintext does not match the text field value
|
||||
return false;
|
||||
}
|
||||
|
||||
log.silly('Filter', 'Filter %s matched message %s', filter.id, prepared.id);
|
||||
|
||||
// we reached the end of the filter, so this means we have a match
|
||||
return filter;
|
||||
}
|
||||
|
||||
function prepareSmapChecks(spamHeader) {
|
||||
function prepareSpamChecks(spamHeader) {
|
||||
return (Array.isArray(spamHeader) ? spamHeader : [].concat(spamHeader || []))
|
||||
.map(header => {
|
||||
if (!header) {
|
||||
|
|
14
package.json
14
package.json
|
@ -19,7 +19,7 @@
|
|||
"grunt-mocha-test": "^0.13.3",
|
||||
"grunt-shell-spawn": "^0.3.10",
|
||||
"grunt-wait": "^0.1.0",
|
||||
"icedfrisby": "^1.3.1",
|
||||
"icedfrisby": "^1.4.0",
|
||||
"mocha": "^4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -34,7 +34,7 @@
|
|||
"iconv-lite": "0.4.19",
|
||||
"ioredfour": "1.0.2-ioredis",
|
||||
"ioredis": "3.1.4",
|
||||
"joi": "11.1.1",
|
||||
"joi": "13.0.0",
|
||||
"js-yaml": "3.10.0",
|
||||
"libbase64": "0.2.0",
|
||||
"libmime": "3.1.0",
|
||||
|
@ -43,17 +43,17 @@
|
|||
"mailsplit": "4.0.2",
|
||||
"mobileconfig": "2.1.0",
|
||||
"mongo-cursor-pagination": "5.0.0",
|
||||
"mongodb": "2.2.31",
|
||||
"nodemailer": "4.1.3",
|
||||
"mongodb": "2.2.33",
|
||||
"nodemailer": "4.2.0",
|
||||
"npmlog": "4.1.2",
|
||||
"openpgp": "2.5.11",
|
||||
"openpgp": "2.5.12",
|
||||
"qrcode": "0.9.0",
|
||||
"restify": "6.0.1",
|
||||
"seq-index": "1.1.0",
|
||||
"smtp-server": "3.3.0",
|
||||
"speakeasy": "2.0.0",
|
||||
"tlds": "1.197.0",
|
||||
"u2f": "^0.1.2",
|
||||
"tlds": "1.198.0",
|
||||
"u2f": "^0.1.3",
|
||||
"utf7": "1.0.2",
|
||||
"uuid": "3.1.0",
|
||||
"wild-config": "1.3.5"
|
||||
|
|
|
@ -6,6 +6,8 @@ Here you can find an example install script to install Wild Duck with Haraka and
|
|||
|
||||
sudo ./install.sh mydomain.com
|
||||
|
||||
Make sure that mydomain.com points to that instance as the install script tries to fetch an SSL certificate from let's Encrypt.
|
||||
|
||||
Where mydomain.com is the domain name of your server.
|
||||
|
||||
If everything succeeds then open your browser http://mydomain.com/ and you should see the Wild Duck example webmail app. Create an account using that app and start receiving and sending emails! (Make sure though that your MX DNS uses mydomain.com)
|
||||
|
|
163
setup/install.sh
163
setup/install.sh
|
@ -3,11 +3,18 @@
|
|||
# Run as root:
|
||||
# sudo ./install.sh [maildomain.com]
|
||||
|
||||
INSTALLDIR=`pwd`
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HOSTNAME="$1"
|
||||
|
||||
WILDDUCK_COMMIT="30f0e83ed34efcaacd56b997d85a0b76ad1cdd8d"
|
||||
ZONEMTA_COMMIT="88f73b6f6fa4c1135af611d1bb79213ed5ee3869"
|
||||
WEBMAIL_COMMIT="bbac73339f192b1dfa39be20ac3a6acf5ffffc07"
|
||||
WEBMAIL_COMMIT="e2453fa150b28a72ccec613a04dfecca1b4e74a1"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root" 1>&2
|
||||
|
@ -65,22 +72,18 @@ mv /etc/wildduck/default.toml /etc/wildduck/wildduck.toml
|
|||
|
||||
echo 'sender="zone-mta"' >> /etc/wildduck/dbs.toml
|
||||
|
||||
echo 'enabled=true
|
||||
port=993
|
||||
host="0.0.0.0"
|
||||
secure=true' > /etc/wildduck/imap.toml
|
||||
|
||||
echo 'enabled=true
|
||||
port=995
|
||||
host="0.0.0.0"
|
||||
secure=true' > /etc/wildduck/pop3.toml
|
||||
sed -i -e "s/999/99/g;s/localhost/$HOSTNAME/g" /etc/wildduck/imap.toml
|
||||
sed -i -e "s/999/99/g;s/localhost/$HOSTNAME/g" /etc/wildduck/pop3.toml
|
||||
|
||||
echo "enabled=true
|
||||
port=24
|
||||
emailDomain=\"$HOSTNAME\"" > /etc/wildduck/lmtp.toml
|
||||
disableSTARTTLS=true" > /etc/wildduck/lmtp.toml
|
||||
|
||||
echo 'user="wildduck"
|
||||
group="wildduck"' | cat - /etc/wildduck/wildduck.toml > temp && mv temp /etc/wildduck/wildduck.toml
|
||||
echo "user=\"wildduck\"
|
||||
group=\"wildduck\"
|
||||
emailDomain=\"$HOSTNAME\"" | cat - /etc/wildduck/wildduck.toml > temp && mv temp /etc/wildduck/wildduck.toml
|
||||
|
||||
sed -i -e "s/localhost:3000/$HOSTNAME/g;s/localhost/$HOSTNAME/g;s/2587/587/g" /etc/wildduck/wildduck.toml
|
||||
|
||||
cd /opt/wildduck
|
||||
sudo npm install --production
|
||||
|
@ -119,9 +122,14 @@ echo "26214400" > config/databytes
|
|||
|
||||
echo "$HOSTNAME" > config/me
|
||||
|
||||
echo "queue/lmtp
|
||||
echo "spf
|
||||
tls
|
||||
queue/lmtp
|
||||
wildduck" > config/plugins
|
||||
|
||||
echo "key=/etc/wildduck/certs/privkey.pem
|
||||
cert=/etc/wildduck/certs/fullchain.pem" > config/tls.ini
|
||||
|
||||
echo "host=127.0.0.1
|
||||
port=24" > config/lmtp.ini
|
||||
|
||||
|
@ -214,6 +222,13 @@ localMxPort=24
|
|||
address=\"127.0.0.1\"
|
||||
name=\"$HOSTNAME\"" > /etc/zone-mta/plugins/wildduck.toml
|
||||
|
||||
sed -i -e "s/test/wildduck/g;s/example.com/$HOSTNAME/g;s/signTransportDomain=true/signTransportDomain=false/g;" /etc/zone-mta/plugins/dkim.toml
|
||||
cd /opt/zone-mta/keys
|
||||
openssl genrsa -out "$HOSTNAME-dkim.pem" 2048
|
||||
chmod 400 "$HOSTNAME-dkim.pem"
|
||||
openssl rsa -in "$HOSTNAME-dkim.pem" -out "$HOSTNAME-dkim.cert" -pubout
|
||||
DNS_ADDRESS="v=DKIM1;p=$(grep -v -e '^-' $HOSTNAME-dkim.cert | tr -d "\n")"
|
||||
|
||||
cd /opt/zone-mta
|
||||
sudo npm install zonemta-wildduck --save
|
||||
sudo npm install --production
|
||||
|
@ -247,7 +262,7 @@ mkdir /opt/wildduck-webmail
|
|||
git --git-dir=/var/opt/wildduck-webmail.git --work-tree=/opt/wildduck-webmail checkout "$WEBMAIL_COMMIT"
|
||||
cp /opt/wildduck-webmail/config/default.toml /etc/wildduck/wildduck-webmail.toml
|
||||
|
||||
sed -i -e "s/localhost/$HOSTNAME/g" /etc/wildduck/wildduck-webmail.toml
|
||||
sed -i -e "s/localhost/$HOSTNAME/g;s/999/99/g;s/2587/587/g" /etc/wildduck/wildduck-webmail.toml
|
||||
|
||||
cd /opt/wildduck-webmail
|
||||
sudo npm install --production
|
||||
|
@ -272,23 +287,36 @@ WantedBy=multi-user.target' > /etc/systemd/system/wildduck-webmail.service
|
|||
|
||||
systemctl enable wildduck-webmail.service
|
||||
|
||||
mv /etc/nginx/sites-available/default /etc/nginx/sites-available/default.bak
|
||||
#### NGINX ####
|
||||
|
||||
echo 'server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
# Create initial certs. These will be overwritten later by Let's Encrypt certs
|
||||
mkdir /etc/wildduck/certs
|
||||
cd /etc/wildduck/certs
|
||||
openssl req -subj "/CN=$HOSTNAME/O=My Company Name LTD./C=US" -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout privkey.pem -out fullchain.pem
|
||||
|
||||
server_name _;
|
||||
chown -R wildduck:wildduck /etc/wildduck/certs
|
||||
chmod 0700 /etc/wildduck/certs/privkey.pem
|
||||
|
||||
# Setup domain without SSL at first, otherwise acme.sh will fail
|
||||
echo "server {
|
||||
listen 80;
|
||||
|
||||
server_name $HOSTNAME;
|
||||
|
||||
ssl_certificate /etc/wildduck/certs/fullchain.pem;
|
||||
ssl_certificate_key /etc/wildduck/certs/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header HOST $http_host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header HOST \$http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_redirect off;
|
||||
}
|
||||
}' > /etc/nginx/sites-available/default
|
||||
}" > "/etc/nginx/sites-available/$HOSTNAME"
|
||||
ln -s "/etc/nginx/sites-available/$HOSTNAME" "/etc/nginx/sites-enabled/$HOSTNAME"
|
||||
systemctl reload nginx
|
||||
|
||||
#### UFW ####
|
||||
|
||||
|
@ -300,6 +328,63 @@ ufw allow 587/tcp
|
|||
ufw allow 993/tcp
|
||||
ufw --force enable
|
||||
|
||||
#### SSL CERTS ####
|
||||
|
||||
curl https://get.acme.sh | sh
|
||||
|
||||
echo 'cert="/etc/wildduck/certs/fullchain.pem"
|
||||
key="/etc/wildduck/certs/privkey.pem"' > /etc/wildduck/tls.toml
|
||||
|
||||
sed -i -e "s/key=/#key=/g;s/cert=/#cert=/g" /etc/zone-mta/interfaces/feeder.toml
|
||||
echo '# @include "../../wildduck/tls.toml"' >> /etc/zone-mta/interfaces/feeder.toml
|
||||
|
||||
# vanity script as first run should not restart anything
|
||||
echo '#!/bin/bash
|
||||
echo "OK"' > /usr/local/bin/reload-services.sh
|
||||
chmod +x /usr/local/bin/reload-services.sh
|
||||
|
||||
/root/.acme.sh/acme.sh --issue --nginx \
|
||||
-d "$HOSTNAME" \
|
||||
--key-file /etc/wildduck/certs/privkey.pem \
|
||||
--fullchain-file /etc/wildduck/certs/fullchain.pem \
|
||||
--reloadcmd "/usr/local/bin/reload-services.sh" \
|
||||
--force || echo "Warning: Failed to generate certificates, using self-signed certs"
|
||||
|
||||
# Update site config, make sure ssl is enabled
|
||||
echo "server {
|
||||
listen 80;
|
||||
server_name $HOSTNAME;
|
||||
return 301 https://\$server_name\$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
|
||||
server_name $HOSTNAME;
|
||||
|
||||
ssl_certificate /etc/wildduck/certs/fullchain.pem;
|
||||
ssl_certificate_key /etc/wildduck/certs/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header HOST \$http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_redirect off;
|
||||
}
|
||||
}" > "/etc/nginx/sites-available/$HOSTNAME"
|
||||
systemctl reload nginx
|
||||
|
||||
# update reload script for future updates
|
||||
echo '#!/bin/bash
|
||||
/bin/systemctl reload nginx
|
||||
/bin/systemctl reload wildduck
|
||||
/bin/systemctl restart zone-mta
|
||||
/bin/systemctl restart haraka
|
||||
/bin/systemctl restart wildduck-webmail' > /usr/local/bin/reload-services.sh
|
||||
chmod +x /usr/local/bin/reload-services.sh
|
||||
|
||||
### start services ####
|
||||
|
||||
systemctl start mongod
|
||||
|
@ -308,3 +393,33 @@ systemctl start haraka
|
|||
systemctl start zone-mta
|
||||
systemctl start wildduck-webmail
|
||||
systemctl reload nginx
|
||||
|
||||
cd "$INSTALLDIR"
|
||||
|
||||
echo "NAMESERVER SETUP
|
||||
================
|
||||
|
||||
MX
|
||||
--
|
||||
Add this MX record to the $HOSTNAME DNS zone:
|
||||
|
||||
$HOSTNAME. IN MX 5 $HOSTNAME.
|
||||
|
||||
SPF
|
||||
---
|
||||
Add this TXT record to the $HOSTNAME DNS zone:
|
||||
|
||||
$HOSTNAME. IN TXT \"v=spf1 a ~all\"
|
||||
|
||||
DKIM
|
||||
----
|
||||
Add this TXT record to the $HOSTNAME DNS zone:
|
||||
|
||||
wildduck._domainkey.$HOSTNAME. IN TXT \"$DNS_ADDRESS\"
|
||||
|
||||
(these settings are stored to $INSTALLDIR/$HOSTNAME-nameserver.txt)" > "$INSTALLDIR/$HOSTNAME-nameserver.txt"
|
||||
|
||||
echo ""
|
||||
cat "$HOSTNAME-nameserver.txt"
|
||||
echo ""
|
||||
echo "All done, open https://$HOSTNAME/ in your browser"
|
||||
|
|
Loading…
Reference in a new issue