Added VERP support
This commit is contained in:
parent
06d5e0d9bf
commit
e5e71e0407
13 changed files with 374 additions and 148 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@ node_modules
|
|||
npm-debug.log
|
||||
.DS_Store
|
||||
development.toml
|
||||
dump.rdb
|
||||
|
|
21
README.md
21
README.md
|
@ -27,7 +27,7 @@ Subscribe to Mailtrain Newsletter [here](http://mailtrain.org/subscription/EysIv
|
|||
|
||||
1. Download and unpack Mailtrain [sources](https://github.com/andris9/mailtrain/archive/master.zip)
|
||||
2. Run `npm install` in the Mailtrain folder to install required dependencies
|
||||
3. Copy [config/default.toml](config/default.toml) as `config/production.toml` and update MySQL Settings in it
|
||||
3. Copy [config/default.toml](config/default.toml) as `config/production.toml` and update MySQL settings in it
|
||||
4. Import SQL tables by running `mysql -u MYSQL_USER -p MYSQL_DB < setup/mailtrain.sql`
|
||||
5. Run the server `NODE_ENV=production npm start`
|
||||
6. Open [http://localhost:3000/](http://localhost:3000/)
|
||||
|
@ -35,6 +35,25 @@ Subscribe to Mailtrain Newsletter [here](http://mailtrain.org/subscription/EysIv
|
|||
8. Navigate to [http://localhost:3000/settings](http://localhost:3000/settings) and update service configuration
|
||||
9. Navigate to [http://localhost:3000/users/account](http://localhost:3000/users/account) and update user information and password
|
||||
|
||||
## Using environment variables
|
||||
|
||||
Some servers expose custom port and hostname options through environment variables. To support these, create a new configuration file `config/local.js`:
|
||||
|
||||
```
|
||||
module.exports = {
|
||||
www: {
|
||||
port: process.env.OPENSHIFT_NODEJS_PORT,
|
||||
host: process.env.OPENSHIFT_NODEJS_IP
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Mailtrain uses [node-config](https://github.com/lorenwest/node-config) for configuration management and thus the config files are loaded in the following order:
|
||||
|
||||
1. default.toml
|
||||
2. {NODE_ENV}.toml (eg. development.toml or production.toml)
|
||||
3. local.js
|
||||
|
||||
### Running behind Nginx proxy
|
||||
|
||||
Edit [mailtrain.nginx](setup/mailtrain.nginx) (update `server_name` directive) and copy it to `/etc/nginx/sites-enabled`
|
||||
|
|
|
@ -5,6 +5,8 @@ level="silly"
|
|||
[www]
|
||||
# HTTP port to listen on
|
||||
port=3000
|
||||
# HTTP interface to listen on
|
||||
host="0.0.0.0"
|
||||
# Secret for signing the session ID cookie
|
||||
secret="a cat"
|
||||
# Session length in seconds when "remember me" is checked
|
||||
|
@ -31,6 +33,12 @@ host="localhost"
|
|||
port=6379
|
||||
db=5
|
||||
|
||||
[verp]
|
||||
enabled=false
|
||||
port=25
|
||||
host="0.0.0.0"
|
||||
|
||||
[testserver]
|
||||
enabled=false
|
||||
port=5587
|
||||
host="0.0.0.0"
|
||||
|
|
6
index.js
6
index.js
|
@ -10,6 +10,7 @@ let app = require('./app');
|
|||
let http = require('http');
|
||||
|
||||
let port = config.www.port;
|
||||
let host = config.www.host;
|
||||
|
||||
log.level = config.log.level;
|
||||
app.set('port', port);
|
||||
|
@ -24,7 +25,7 @@ let server = http.createServer(app);
|
|||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
|
||||
server.listen(port);
|
||||
server.listen(port, host);
|
||||
|
||||
server.on('error', err => {
|
||||
if (err.syscall !== 'listen') {
|
||||
|
@ -56,5 +57,6 @@ server.on('listening', () => {
|
|||
// start sending loop
|
||||
require('./services/sender'); // eslint-disable-line global-require
|
||||
require('./services/importer'); // eslint-disable-line global-require
|
||||
require('./services/testserver'); // eslint-disable-line global-require
|
||||
require('./services/verp-server'); // eslint-disable-line global-require
|
||||
require('./services/test-server'); // eslint-disable-line global-require
|
||||
});
|
||||
|
|
|
@ -82,7 +82,7 @@ function getTemplate(template, callback) {
|
|||
}
|
||||
|
||||
function createMailer(callback) {
|
||||
settings.list(['smtpHostname', 'smtpPort', 'smtpEncryption', 'smtpUser', 'smtpPass', 'smtpLog', 'smtpMaxConnections', 'smtpMaxMessages'], (err, configItems) => {
|
||||
settings.list(['smtpHostname', 'smtpPort', 'smtpEncryption', 'smtpUser', 'smtpPass', 'smtpLog', 'smtpDisableAuth', 'smtpMaxConnections', 'smtpMaxMessages', 'smtpSelfSigned'], (err, configItems) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
@ -92,7 +92,7 @@ function createMailer(callback) {
|
|||
port: Number(configItems.smtpPort) || false,
|
||||
secure: configItems.smtpEncryption === 'TLS',
|
||||
ignoreTLS: configItems.smtpEncryption === 'NONE',
|
||||
auth: {
|
||||
auth: configItems.smtpDisableAuth ? false : {
|
||||
user: configItems.smtpUser,
|
||||
pass: configItems.smtpPass
|
||||
},
|
||||
|
@ -103,7 +103,10 @@ function createMailer(callback) {
|
|||
error: log.info.bind(log, 'Mail')
|
||||
},
|
||||
maxConnections: Number(configItems.smtpMaxConnections),
|
||||
maxMessages: Number(configItems.smtpMaxMessages)
|
||||
maxMessages: Number(configItems.smtpMaxMessages),
|
||||
tls: {
|
||||
rejectUnauthorized: !configItems.smtpSelfSigned
|
||||
}
|
||||
});
|
||||
|
||||
return callback(null, module.exports.transport);
|
||||
|
|
|
@ -5,6 +5,7 @@ let db = require('../db');
|
|||
let lists = require('./lists');
|
||||
let templates = require('./templates');
|
||||
let segments = require('./segments');
|
||||
let subscriptions = require('./subscriptions');
|
||||
let shortid = require('shortid');
|
||||
|
||||
let allowedKeys = ['description', 'from', 'address', 'subject', 'template', 'list', 'segment', 'html', 'text'];
|
||||
|
@ -424,7 +425,7 @@ module.exports.getMail = (campaignId, listId, subscriptionId, callback) => {
|
|||
});
|
||||
};
|
||||
|
||||
module.exports.findMail = (responseId, callback) => {
|
||||
module.exports.findMailByResponse = (responseId, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
|
@ -466,6 +467,105 @@ module.exports.findMail = (responseId, callback) => {
|
|||
});
|
||||
};
|
||||
|
||||
module.exports.findMailByCampaign = (campaignHeader, callback) => {
|
||||
if (!campaignHeader) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
let parts = campaignHeader.split('.');
|
||||
let cCid = parts.shift();
|
||||
let sCid = parts.pop();
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
let query = 'SELECT `id`, `list`, `segment` FROM `campaigns` WHERE `cid`=? LIMIT 1';
|
||||
connection.query(query, [cCid], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
if (!rows || !rows.length) {
|
||||
connection.release();
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
let campaignId = rows[0].id;
|
||||
let listId = rows[0].list;
|
||||
let segmentId = rows[0].segment;
|
||||
|
||||
let query = 'SELECT id FROM `subscription__' + listId + '` WHERE cid=? LIMIT 1';
|
||||
connection.query(query, [sCid], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
if (!rows || !rows.length) {
|
||||
connection.release();
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
let subscriptionId = rows[0].id;
|
||||
|
||||
let query = 'SELECT `id`, `list`, `segment`, `subscription` FROM `campaign__' + campaignId + '` WHERE `list`=? AND `segment`=? AND `subscription`=? LIMIT 1';
|
||||
connection.query(query, [listId, segmentId, subscriptionId], (err, rows) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (!rows || !rows.length) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
let message = rows[0];
|
||||
message.campaign = campaignId;
|
||||
|
||||
return callback(null, message);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.updateMessage = (message, status, updateSubscription, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let statusCode;
|
||||
if (status === 'unsubscribed') {
|
||||
statusCode = 2;
|
||||
}
|
||||
if (status === 'bounced') {
|
||||
statusCode = 3;
|
||||
}
|
||||
if (status === 'complained') {
|
||||
statusCode = 4;
|
||||
}
|
||||
|
||||
let query = 'UPDATE `campaigns` SET `' + status + '`=`' + status + '`+1 WHERE id=? LIMIT 1';
|
||||
connection.query(query, [message.campaign], () => {
|
||||
|
||||
let query = 'UPDATE `campaign__' + message.campaign + '` SET status=?, updated=NOW() WHERE id=? LIMIT 1';
|
||||
connection.query(query, [statusCode, message.id], err => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (updateSubscription) {
|
||||
subscriptions.changeStatus(message.subscription, message.list, statusCode === 2 ? message.campaign : false, statusCode, callback);
|
||||
} else {
|
||||
return callback(null, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
function createCampaignTables(id, callback) {
|
||||
let query = 'CREATE TABLE `campaign__' + id + '` LIKE campaign';
|
||||
db.getConnection((err, connection) => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "mailtrain",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0-beta.0",
|
||||
"description": "Self hosted email newsletter app",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -27,6 +27,7 @@
|
|||
"dependencies": {
|
||||
"bcrypt-nodejs": "0.0.3",
|
||||
"body-parser": "^1.15.0",
|
||||
"bounce-handler": "^7.3.2-fork.0",
|
||||
"compression": "^1.6.1",
|
||||
"config": "^1.20.0",
|
||||
"connect-flash": "^0.1.1",
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
'use strict';
|
||||
|
||||
let config = require('config');
|
||||
let passport = require('../lib/passport');
|
||||
let express = require('express');
|
||||
let router = new express.Router();
|
||||
let tools = require('../lib/tools');
|
||||
let nodemailer = require('nodemailer');
|
||||
let mailer = require('../lib/mailer');
|
||||
let url = require('url');
|
||||
|
||||
let settings = require('../lib/models/settings');
|
||||
|
||||
let allowedKeys = ['service_url', 'smtp_hostname', 'smtp_port', 'smtp_encryption', 'smtp_user', 'smtp_pass', 'admin_email', 'smtp_log', 'smtp_max_connections', 'smtp_max_messages', 'default_from', 'default_address', 'default_subject', 'default_homepage', 'default_postaddress', 'default_sender'];
|
||||
let allowedKeys = ['service_url', 'smtp_hostname', 'smtp_port', 'smtp_encryption', 'smtp_disable_auth', 'smtp_user', 'smtp_pass', 'admin_email', 'smtp_log', 'smtp_max_connections', 'smtp_max_messages', 'smtp_self_signed', 'default_from', 'default_address', 'default_subject', 'default_homepage', 'default_postaddress', 'default_sender', 'verp_hostname', 'verp_use'];
|
||||
|
||||
router.all('/*', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
|
@ -42,6 +44,10 @@ router.get('/', passport.csrfProtection, (req, res, next) => {
|
|||
value: 'Do not use encryption'
|
||||
}];
|
||||
|
||||
let urlparts = url.parse(configItems.serviceUrl);
|
||||
configItems.verpHostname = configItems.verpHostname || 'bounces.' + (urlparts.hostname || 'localhost');
|
||||
|
||||
configItems.verpEnabled = config.verp.enabled;
|
||||
configItems.csrfToken = req.csrfToken();
|
||||
res.render('settings', configItems);
|
||||
});
|
||||
|
@ -68,11 +74,14 @@ router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) =
|
|||
values.push(value);
|
||||
}
|
||||
});
|
||||
// checkbox is not included in value listing if left unchecked
|
||||
if (keys.indexOf('smtp_log') < 0) {
|
||||
keys.push('smtp_log');
|
||||
|
||||
// checkboxs are not included in value listing if left unchecked
|
||||
['smtp_log', 'smtp_self_signed', 'smtp_disable_auth', 'verp_use'].forEach(key => {
|
||||
if (keys.indexOf(key) < 0) {
|
||||
keys.push(key);
|
||||
values.push('');
|
||||
}
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
let storeSettings = () => {
|
||||
|
@ -110,9 +119,12 @@ router.post('/smtp-verify', passport.parseForm, passport.csrfProtection, (req, r
|
|||
port: Number(configItems.smtpPort) || false,
|
||||
secure: configItems.smtpEncryption === 'TLS',
|
||||
ignoreTLS: configItems.smtpEncryption === 'NONE',
|
||||
auth: {
|
||||
auth: configItems.smtpDisableAuth ? false : {
|
||||
user: configItems.smtpUser,
|
||||
pass: configItems.smtpPass
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: !configItems.smtpSelfSigned
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -4,8 +4,6 @@ let express = require('express');
|
|||
let router = new express.Router();
|
||||
let request = require('request');
|
||||
let campaigns = require('../lib/models/campaigns');
|
||||
let subscriptions = require('../lib/models/subscriptions');
|
||||
let db = require('../lib/db');
|
||||
let log = require('npmlog');
|
||||
let multer = require('multer');
|
||||
let uploads = multer();
|
||||
|
@ -42,14 +40,14 @@ router.post('/aws', (req, res, next) => {
|
|||
}
|
||||
|
||||
if (req.body.Message.mail && req.body.Message.mail.messageId) {
|
||||
campaigns.findMail(req.body.Message.mail.messageId, (err, message) => {
|
||||
campaigns.findMailByResponse(req.body.Message.mail.messageId, (err, message) => {
|
||||
if (err || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (req.body.Message.notificationType) {
|
||||
case 'Bounce':
|
||||
updateMessage(message, 'bounced', ['Undetermined', 'Permanent'].indexOf(req.body.Message.bounce.bounceType) >= 0, (err, updated) => {
|
||||
campaigns.updateMessage(message, 'bounced', ['Undetermined', 'Permanent'].indexOf(req.body.Message.bounce.bounceType) >= 0, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('AWS', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -59,7 +57,7 @@ router.post('/aws', (req, res, next) => {
|
|||
break;
|
||||
case 'Complaint':
|
||||
if (req.body.Message.complaint) {
|
||||
updateMessage(message, 'complained', true, (err, updated) => {
|
||||
campaigns.updateMessage(message, 'complained', true, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('AWS', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -105,7 +103,7 @@ router.post('/sparkpost', (req, res, next) => {
|
|||
return processEvents();
|
||||
}
|
||||
|
||||
getMessage(evt.campaign_id, (err, message) => {
|
||||
campaigns.findMailByCampaign(evt.campaign_id, (err, message) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
@ -117,7 +115,7 @@ router.post('/sparkpost', (req, res, next) => {
|
|||
switch (evt.type) {
|
||||
case 'bounce':
|
||||
// https://support.sparkpost.com/customer/portal/articles/1929896
|
||||
return updateMessage(message, 'bounced', [1, 10, 25, 30, 50].indexOf(Number(evt.bounce_class)) >= 0, (err, updated) => {
|
||||
return campaigns.updateMessage(message, 'bounced', [1, 10, 25, 30, 50].indexOf(Number(evt.bounce_class)) >= 0, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('Sparkpost', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -126,7 +124,7 @@ router.post('/sparkpost', (req, res, next) => {
|
|||
return processEvents();
|
||||
});
|
||||
case 'spam_complaint':
|
||||
return updateMessage(message, 'complained', true, (err, updated) => {
|
||||
return campaigns.updateMessage(message, 'complained', true, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('Sparkpost', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -135,7 +133,7 @@ router.post('/sparkpost', (req, res, next) => {
|
|||
return processEvents();
|
||||
});
|
||||
case 'link_unsubscribe':
|
||||
return updateMessage(message, 'unsubscribed', true, (err, updated) => {
|
||||
return campaigns.updateMessage(message, 'unsubscribed', true, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('Sparkpost', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -171,7 +169,7 @@ router.post('/sendgrid', (req, res, next) => {
|
|||
return processEvents();
|
||||
}
|
||||
|
||||
getMessage(evt.campaign_id, (err, message) => {
|
||||
campaigns.findMailByCampaign(evt.campaign_id, (err, message) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
@ -183,7 +181,7 @@ router.post('/sendgrid', (req, res, next) => {
|
|||
switch (evt.event) {
|
||||
case 'bounce':
|
||||
// https://support.sparkpost.com/customer/portal/articles/1929896
|
||||
return updateMessage(message, 'bounced', true, (err, updated) => {
|
||||
return campaigns.updateMessage(message, 'bounced', true, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('Sendgrid', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -192,7 +190,7 @@ router.post('/sendgrid', (req, res, next) => {
|
|||
return processEvents();
|
||||
});
|
||||
case 'spamreport':
|
||||
return updateMessage(message, 'complained', true, (err, updated) => {
|
||||
return campaigns.updateMessage(message, 'complained', true, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('Sendgrid', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -202,7 +200,7 @@ router.post('/sendgrid', (req, res, next) => {
|
|||
});
|
||||
case 'group_unsubscribe':
|
||||
case 'unsubscribe':
|
||||
return updateMessage(message, 'unsubscribed', true, (err, updated) => {
|
||||
return campaigns.updateMessage(message, 'unsubscribed', true, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('Sendgrid', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -222,14 +220,14 @@ router.post('/sendgrid', (req, res, next) => {
|
|||
|
||||
router.post('/mailgun', uploads.any(), (req, res) => {
|
||||
let evt = req.body;
|
||||
getMessage([].concat(evt && evt.campaign_id || []).shift(), (err, message) => {
|
||||
campaigns.findMailByCampaign([].concat(evt && evt.campaign_id || []).shift(), (err, message) => {
|
||||
if (err || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (evt.event) {
|
||||
case 'bounced':
|
||||
return updateMessage(message, 'bounced', true, (err, updated) => {
|
||||
return campaigns.updateMessage(message, 'bounced', true, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('Mailgun', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -237,7 +235,7 @@ router.post('/mailgun', uploads.any(), (req, res) => {
|
|||
}
|
||||
});
|
||||
case 'complained':
|
||||
return updateMessage(message, 'complained', true, (err, updated) => {
|
||||
return campaigns.updateMessage(message, 'complained', true, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('Mailgun', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -245,7 +243,7 @@ router.post('/mailgun', uploads.any(), (req, res) => {
|
|||
}
|
||||
});
|
||||
case 'unsubscribed':
|
||||
return updateMessage(message, 'unsubscribed', true, (err, updated) => {
|
||||
return campaigns.updateMessage(message, 'unsubscribed', true, (err, updated) => {
|
||||
if (err) {
|
||||
log.error('Mailgun', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
|
@ -262,102 +260,3 @@ router.post('/mailgun', uploads.any(), (req, res) => {
|
|||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
function getMessage(messageHeader, callback) {
|
||||
if (!messageHeader) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
let parts = messageHeader.split('.');
|
||||
let cCid = parts.shift();
|
||||
let sCid = parts.pop();
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
let query = 'SELECT `id`, `list`, `segment` FROM `campaigns` WHERE `cid`=? LIMIT 1';
|
||||
connection.query(query, [cCid], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
if (!rows || !rows.length) {
|
||||
connection.release();
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
let campaignId = rows[0].id;
|
||||
let listId = rows[0].list;
|
||||
let segmentId = rows[0].segment;
|
||||
|
||||
let query = 'SELECT id FROM `subscription__' + listId + '` WHERE cid=? LIMIT 1';
|
||||
connection.query(query, [sCid], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
return callback(err);
|
||||
}
|
||||
if (!rows || !rows.length) {
|
||||
connection.release();
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
let subscriptionId = rows[0].id;
|
||||
|
||||
let query = 'SELECT `id`, `list`, `segment`, `subscription` FROM `campaign__' + campaignId + '` WHERE `list`=? AND `segment`=? AND `subscription`=? LIMIT 1';
|
||||
connection.query(query, [listId, segmentId, subscriptionId], (err, rows) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (!rows || !rows.length) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
let message = rows[0];
|
||||
message.campaign = campaignId;
|
||||
|
||||
return callback(null, message);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateMessage(message, status, updateSubscription, callback) {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let statusCode;
|
||||
if (status === 'unsubscribed') {
|
||||
statusCode = 2;
|
||||
}
|
||||
if (status === 'bounced') {
|
||||
statusCode = 3;
|
||||
}
|
||||
if (status === 'complained') {
|
||||
statusCode = 4;
|
||||
}
|
||||
|
||||
let query = 'UPDATE `campaigns` SET `' + status + '`=`' + status + '`+1 WHERE id=? LIMIT 1';
|
||||
connection.query(query, [message.campaign], () => {
|
||||
|
||||
let query = 'UPDATE `campaign__' + message.campaign + '` SET status=?, updated=NOW() WHERE id=? LIMIT 1';
|
||||
connection.query(query, [statusCode, message.id], err => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (updateSubscription) {
|
||||
subscriptions.changeStatus(message.subscription, message.list, statusCode === 2 ? message.campaign : false, statusCode, callback);
|
||||
} else {
|
||||
return callback(null, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
let log = require('npmlog');
|
||||
|
||||
let config = require('config');
|
||||
let db = require('../lib/db');
|
||||
let tools = require('../lib/tools');
|
||||
let mailer = require('../lib/mailer');
|
||||
|
@ -111,11 +111,13 @@ function formatMessage(message, callback) {
|
|||
return callback(new Error('List not found'));
|
||||
}
|
||||
|
||||
settings.get('serviceUrl', (err, serviceUrl) => {
|
||||
settings.list(['serviceUrl', 'verpUse', 'verpHostname'], (err, configItems) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let useVerp = config.verp.enabled && configItems.verpUse && configItems.verpHostname;
|
||||
|
||||
fields.list(list.id, (err, fieldList) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
|
@ -141,7 +143,7 @@ function formatMessage(message, callback) {
|
|||
}
|
||||
});
|
||||
|
||||
links.updateLinks(campaign, list, message.subscription, serviceUrl, campaign.html, (err, html) => {
|
||||
links.updateLinks(campaign, list, message.subscription, configItems.serviceUrl, campaign.html, (err, html) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
@ -157,43 +159,52 @@ function formatMessage(message, callback) {
|
|||
return prefix + 'cid:' + cid;
|
||||
});
|
||||
|
||||
let campaignAddress = [campaign.cid, list.cid, message.subscription.cid].join('.');
|
||||
|
||||
return callback(null, {
|
||||
from: {
|
||||
name: campaign.from,
|
||||
address: campaign.address
|
||||
},
|
||||
xMailer: 'Mailtrain Mailer (+http://mailtrain.org)',
|
||||
xMailer: 'Mailtrain Mailer (+https://mailtrain.org)',
|
||||
to: {
|
||||
name: [].concat(message.subscription.firstName || []).concat(message.subscription.lastName || []).join(' '),
|
||||
address: message.subscription.email
|
||||
},
|
||||
sender: useVerp ? campaignAddress + '@' + configItems.verpHostname : false,
|
||||
|
||||
envelope: useVerp ? {
|
||||
from: campaignAddress + '@' + configItems.verpHostname,
|
||||
to: message.subscription.email
|
||||
} : false,
|
||||
|
||||
headers: {
|
||||
'x-fbl': [campaign.cid, list.cid, message.subscription.cid].join('.'),
|
||||
'x-fbl': campaignAddress,
|
||||
// custom header for SparkPost
|
||||
'x-msys-api': JSON.stringify({
|
||||
campaign_id: [campaign.cid, list.cid, message.subscription.cid].join('.')
|
||||
campaign_id: campaignAddress
|
||||
}),
|
||||
// custom header for SendGrid
|
||||
'x-smtpapi': JSON.stringify({
|
||||
unique_args: {
|
||||
campaign_id: [campaign.cid, list.cid, message.subscription.cid].join('.')
|
||||
campaign_id: campaignAddress
|
||||
}
|
||||
}),
|
||||
// custom header for Mailgun
|
||||
'x-mailgun-variables': JSON.stringify({
|
||||
campaign_id: [campaign.cid, list.cid, message.subscription.cid].join('.')
|
||||
campaign_id: campaignAddress
|
||||
}),
|
||||
'List-ID': {
|
||||
prepared: true,
|
||||
value: '"' + list.name.replace(/[^a-z0-9\s'.,\-]/g, '').trim() + '" <' + list.cid + '.' + (url.parse(serviceUrl).hostname || 'localhost') + '>'
|
||||
value: '"' + list.name.replace(/[^a-z0-9\s'.,\-]/g, '').trim() + '" <' + list.cid + '.' + (url.parse(configItems.serviceUrl).hostname || 'localhost') + '>'
|
||||
}
|
||||
},
|
||||
list: {
|
||||
unsubscribe: url.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid + '?auto=yes')
|
||||
unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid + '?auto=yes')
|
||||
},
|
||||
subject: tools.formatMessage(serviceUrl, campaign, list, message.subscription, campaign.subject),
|
||||
html: tools.formatMessage(serviceUrl, campaign, list, message.subscription, html),
|
||||
text: tools.formatMessage(serviceUrl, campaign, list, message.subscription, campaign.text),
|
||||
subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject),
|
||||
html: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html),
|
||||
text: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.text),
|
||||
|
||||
attachments
|
||||
});
|
||||
|
|
|
@ -4,7 +4,6 @@ let log = require('npmlog');
|
|||
let config = require('config');
|
||||
let crypto = require('crypto');
|
||||
|
||||
// Replace '../lib/smtp-server' with 'smtp-server' when running this script outside this directory
|
||||
let SMTPServer = require('smtp-server').SMTPServer;
|
||||
|
||||
// Setup server
|
||||
|
@ -98,7 +97,7 @@ server.on('error', err => {
|
|||
});
|
||||
|
||||
if (config.testserver.enabled) {
|
||||
server.listen(config.testserver.port, () => {
|
||||
server.listen(config.testserver.port, config.testserver.host, () => {
|
||||
log.info('TESTSERV', 'Server listening on port %s', config.testserver.port);
|
||||
});
|
||||
}
|
103
services/verp-server.js
Normal file
103
services/verp-server.js
Normal file
|
@ -0,0 +1,103 @@
|
|||
'use strict';
|
||||
|
||||
let log = require('npmlog');
|
||||
let config = require('config');
|
||||
let settings = require('../lib/models/settings');
|
||||
let campaigns = require('../lib/models/campaigns');
|
||||
let BounceHandler = require('bounce-handler').BounceHandler;
|
||||
let SMTPServer = require('smtp-server').SMTPServer;
|
||||
|
||||
// Setup server
|
||||
let server = new SMTPServer({
|
||||
|
||||
// log to console
|
||||
logger: false,
|
||||
|
||||
banner: 'Mailtrain VERP bouncer',
|
||||
|
||||
disabledCommands: ['AUTH', 'STARTTLS'],
|
||||
|
||||
onRcptTo: (address, session, callback) => {
|
||||
|
||||
settings.list(['verpHostname'], (err, configItems) => {
|
||||
if (err) {
|
||||
err = new Error('Failed to load configuration');
|
||||
err.responseCode = 421;
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let user = address.address.split('@').shift();
|
||||
let host = address.address.split('@').pop();
|
||||
|
||||
if (host !== configItems.verpHostname || !/^[a-z0-9_\-]+\.[a-z0-9_\-]+\.[a-z0-9_\-]+$/i.test(user)) {
|
||||
err = new Error('Unknown user ' + address.address);
|
||||
err.responseCode = 510;
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
campaigns.findMailByCampaign(user, (err, message) => {
|
||||
if (err) {
|
||||
err = new Error('Failed to load user data');
|
||||
err.responseCode = 421;
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
err = new Error('Unknown user ' + address.address);
|
||||
err.responseCode = 510;
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
session.campaignId = user;
|
||||
session.message = message;
|
||||
|
||||
log.verbose('VERP', 'Incoming message for Campaign %s, List %s, Subscription %s', message.campaign, message.list, message.subscription);
|
||||
|
||||
callback();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Handle message stream
|
||||
onData: (stream, session, callback) => {
|
||||
let chunks = [];
|
||||
let chunklen = 0;
|
||||
stream.on('data', chunk => {
|
||||
if (!chunk || !chunk.length || chunklen > 60 * 1024) {
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
chunklen += chunk.length;
|
||||
});
|
||||
stream.on('end', () => {
|
||||
|
||||
let body = Buffer.concat(chunks, chunklen).toString();
|
||||
|
||||
let bh = new BounceHandler();
|
||||
let bounceResult = [].concat(bh.parse_email(body) || []).shift();
|
||||
|
||||
if (!bounceResult || ['failed', 'transient'].indexOf(bounceResult.action) < 0) {
|
||||
return callback(null, 'Message accepted');
|
||||
} else {
|
||||
campaigns.updateMessage(session.message, 'bounced', bounceResult.action === 'failed', (err, updated) => {
|
||||
if (err) {
|
||||
log.error('VERP', 'Failed updating message: %s', err.stack);
|
||||
} else if (updated) {
|
||||
log.verbose('VERP', 'Marked message %s as unsubscribed', session.campaignId);
|
||||
}
|
||||
callback(null, 'Message accepted');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', err => {
|
||||
log.error('VERP', err.stack);
|
||||
});
|
||||
|
||||
if (config.verp.enabled) {
|
||||
server.listen(config.verp.port, () => {
|
||||
log.info('VERP', 'Server listening on port %s', config.verp.port);
|
||||
});
|
||||
}
|
|
@ -122,6 +122,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-xs-4">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="smtp-disable-auth" {{#if smtpDisableAuth}} checked {{/if}}> Disable SMTP authentication
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="smtp-user" class="col-sm-2 control-label">Username</label>
|
||||
<div class="col-sm-10">
|
||||
|
@ -145,6 +155,17 @@
|
|||
<legend>
|
||||
Advanced SMTP settings
|
||||
</legend>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-xs-4">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="smtp-self-signed" {{#if smtpSelfSigned}} checked {{/if}}> Allow self-signed certificates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="smtp-max-connections" class="col-sm-2 control-label">Max connections</label>
|
||||
<div class="col-sm-4">
|
||||
|
@ -172,6 +193,53 @@
|
|||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>
|
||||
VERP bounce handling
|
||||
</legend>
|
||||
|
||||
<p class="text-info">
|
||||
Mailtrain is able to use <a href="https://en.wikipedia.org/wiki/Variable_envelope_return_path">VERP</a> based routing to detect bounces. In this case the message is sent to the recipient using a custom VERP address as the return path of the
|
||||
message. If the message is not accepted a bounce email is sent to this special VERP address and thus a bounce is detected.
|
||||
</p>
|
||||
|
||||
<p class="text-info">
|
||||
To get VERP working you need to set up a DNS MX record that points to your Mailtrain hostname. You must also ensure that Mailtrain VERP interface is available from port 25 of your server (port 25 usually requires root user privileges). This way if anyone tries to send email to <code>someuser@{{verpHostname}}</code> then the email should end up to this server.
|
||||
</p>
|
||||
|
||||
<p class="text-warning">
|
||||
VERP usually only works if you are using your own SMTP server. Regural relay services (SES, SparkPost, Gmail etc.) tend to remove the VERP address from the message.
|
||||
</p>
|
||||
|
||||
{{#if verpEnabled}}
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-xs-4">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="verp-use" {{#if verpUse}} checked {{/if}}> Use VERP to catch bounces
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="verp-hostname" class="col-sm-2 control-label">Server hostname</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="verp-hostname" id="verp-hostname" placeholder="The VERP server hostname, eg. bounces.example.com" value="{{verpHostname}}">
|
||||
<span class="help-block">VERP bounce handling server hostname. This hostname is used in the SMTP envelope FROM address and the MX DNS records should point to this server</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{else}}
|
||||
<div class="form-group">
|
||||
<div class="col-sm-10">
|
||||
<p class="form-control-static">VERP bounce handling server is not enabled. Modify your server configuration file and restart server to enable it</p>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</fieldset>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue