use filtering handler for filters

This commit is contained in:
Andris Reinman 2017-10-18 12:42:51 +03:00
parent e6cb31b9a9
commit dc6714d1d9
5 changed files with 666 additions and 397 deletions

464
lib/filter-handler.js Normal file
View 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
View file

@ -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) {

View file

@ -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"

View file

@ -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)

View file

@ -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"