Added VERP support

This commit is contained in:
Andris Reinman 2016-04-10 20:26:20 -07:00
parent 06d5e0d9bf
commit e5e71e0407
13 changed files with 374 additions and 148 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ node_modules
npm-debug.log npm-debug.log
.DS_Store .DS_Store
development.toml development.toml
dump.rdb

View file

@ -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) 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 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` 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` 5. Run the server `NODE_ENV=production npm start`
6. Open [http://localhost:3000/](http://localhost:3000/) 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 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 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 ### Running behind Nginx proxy
Edit [mailtrain.nginx](setup/mailtrain.nginx) (update `server_name` directive) and copy it to `/etc/nginx/sites-enabled` Edit [mailtrain.nginx](setup/mailtrain.nginx) (update `server_name` directive) and copy it to `/etc/nginx/sites-enabled`

View file

@ -5,6 +5,8 @@ level="silly"
[www] [www]
# HTTP port to listen on # HTTP port to listen on
port=3000 port=3000
# HTTP interface to listen on
host="0.0.0.0"
# Secret for signing the session ID cookie # Secret for signing the session ID cookie
secret="a cat" secret="a cat"
# Session length in seconds when "remember me" is checked # Session length in seconds when "remember me" is checked
@ -31,6 +33,12 @@ host="localhost"
port=6379 port=6379
db=5 db=5
[verp]
enabled=false
port=25
host="0.0.0.0"
[testserver] [testserver]
enabled=false enabled=false
port=5587 port=5587
host="0.0.0.0"

View file

@ -10,6 +10,7 @@ let app = require('./app');
let http = require('http'); let http = require('http');
let port = config.www.port; let port = config.www.port;
let host = config.www.host;
log.level = config.log.level; log.level = config.log.level;
app.set('port', port); app.set('port', port);
@ -24,7 +25,7 @@ let server = http.createServer(app);
* Listen on provided port, on all network interfaces. * Listen on provided port, on all network interfaces.
*/ */
server.listen(port); server.listen(port, host);
server.on('error', err => { server.on('error', err => {
if (err.syscall !== 'listen') { if (err.syscall !== 'listen') {
@ -56,5 +57,6 @@ server.on('listening', () => {
// start sending loop // start sending loop
require('./services/sender'); // eslint-disable-line global-require require('./services/sender'); // eslint-disable-line global-require
require('./services/importer'); // 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
}); });

View file

@ -82,7 +82,7 @@ function getTemplate(template, callback) {
} }
function createMailer(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) { if (err) {
return callback(err); return callback(err);
} }
@ -92,7 +92,7 @@ function createMailer(callback) {
port: Number(configItems.smtpPort) || false, port: Number(configItems.smtpPort) || false,
secure: configItems.smtpEncryption === 'TLS', secure: configItems.smtpEncryption === 'TLS',
ignoreTLS: configItems.smtpEncryption === 'NONE', ignoreTLS: configItems.smtpEncryption === 'NONE',
auth: { auth: configItems.smtpDisableAuth ? false : {
user: configItems.smtpUser, user: configItems.smtpUser,
pass: configItems.smtpPass pass: configItems.smtpPass
}, },
@ -103,7 +103,10 @@ function createMailer(callback) {
error: log.info.bind(log, 'Mail') error: log.info.bind(log, 'Mail')
}, },
maxConnections: Number(configItems.smtpMaxConnections), maxConnections: Number(configItems.smtpMaxConnections),
maxMessages: Number(configItems.smtpMaxMessages) maxMessages: Number(configItems.smtpMaxMessages),
tls: {
rejectUnauthorized: !configItems.smtpSelfSigned
}
}); });
return callback(null, module.exports.transport); return callback(null, module.exports.transport);

View file

@ -5,6 +5,7 @@ let db = require('../db');
let lists = require('./lists'); let lists = require('./lists');
let templates = require('./templates'); let templates = require('./templates');
let segments = require('./segments'); let segments = require('./segments');
let subscriptions = require('./subscriptions');
let shortid = require('shortid'); let shortid = require('shortid');
let allowedKeys = ['description', 'from', 'address', 'subject', 'template', 'list', 'segment', 'html', 'text']; 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) => { db.getConnection((err, connection) => {
if (err) { if (err) {
return callback(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) { function createCampaignTables(id, callback) {
let query = 'CREATE TABLE `campaign__' + id + '` LIKE campaign'; let query = 'CREATE TABLE `campaign__' + id + '` LIKE campaign';
db.getConnection((err, connection) => { db.getConnection((err, connection) => {

View file

@ -1,7 +1,7 @@
{ {
"name": "mailtrain", "name": "mailtrain",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.0-beta.0",
"description": "Self hosted email newsletter app", "description": "Self hosted email newsletter app",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@ -15,8 +15,8 @@
"author": "Andris Reinman", "author": "Andris Reinman",
"license": "GPL-3.0", "license": "GPL-3.0",
"homepage": "http://mailtrain.org", "homepage": "http://mailtrain.org",
"engines" : { "engines": {
"node" : ">=5.0.0" "node": ">=5.0.0"
}, },
"devDependencies": { "devDependencies": {
"grunt": "^1.0.1", "grunt": "^1.0.1",
@ -27,6 +27,7 @@
"dependencies": { "dependencies": {
"bcrypt-nodejs": "0.0.3", "bcrypt-nodejs": "0.0.3",
"body-parser": "^1.15.0", "body-parser": "^1.15.0",
"bounce-handler": "^7.3.2-fork.0",
"compression": "^1.6.1", "compression": "^1.6.1",
"config": "^1.20.0", "config": "^1.20.0",
"connect-flash": "^0.1.1", "connect-flash": "^0.1.1",

View file

@ -1,15 +1,17 @@
'use strict'; 'use strict';
let config = require('config');
let passport = require('../lib/passport'); let passport = require('../lib/passport');
let express = require('express'); let express = require('express');
let router = new express.Router(); let router = new express.Router();
let tools = require('../lib/tools'); let tools = require('../lib/tools');
let nodemailer = require('nodemailer'); let nodemailer = require('nodemailer');
let mailer = require('../lib/mailer'); let mailer = require('../lib/mailer');
let url = require('url');
let settings = require('../lib/models/settings'); 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) => { router.all('/*', (req, res, next) => {
if (!req.user) { if (!req.user) {
@ -42,6 +44,10 @@ router.get('/', passport.csrfProtection, (req, res, next) => {
value: 'Do not use encryption' 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(); configItems.csrfToken = req.csrfToken();
res.render('settings', configItems); res.render('settings', configItems);
}); });
@ -68,11 +74,14 @@ router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) =
values.push(value); values.push(value);
} }
}); });
// checkbox is not included in value listing if left unchecked
if (keys.indexOf('smtp_log') < 0) { // checkboxs are not included in value listing if left unchecked
keys.push('smtp_log'); ['smtp_log', 'smtp_self_signed', 'smtp_disable_auth', 'verp_use'].forEach(key => {
values.push(''); if (keys.indexOf(key) < 0) {
} keys.push(key);
values.push('');
}
});
let i = 0; let i = 0;
let storeSettings = () => { let storeSettings = () => {
@ -110,9 +119,12 @@ router.post('/smtp-verify', passport.parseForm, passport.csrfProtection, (req, r
port: Number(configItems.smtpPort) || false, port: Number(configItems.smtpPort) || false,
secure: configItems.smtpEncryption === 'TLS', secure: configItems.smtpEncryption === 'TLS',
ignoreTLS: configItems.smtpEncryption === 'NONE', ignoreTLS: configItems.smtpEncryption === 'NONE',
auth: { auth: configItems.smtpDisableAuth ? false : {
user: configItems.smtpUser, user: configItems.smtpUser,
pass: configItems.smtpPass pass: configItems.smtpPass
},
tls: {
rejectUnauthorized: !configItems.smtpSelfSigned
} }
}); });

View file

@ -4,8 +4,6 @@ let express = require('express');
let router = new express.Router(); let router = new express.Router();
let request = require('request'); let request = require('request');
let campaigns = require('../lib/models/campaigns'); let campaigns = require('../lib/models/campaigns');
let subscriptions = require('../lib/models/subscriptions');
let db = require('../lib/db');
let log = require('npmlog'); let log = require('npmlog');
let multer = require('multer'); let multer = require('multer');
let uploads = multer(); let uploads = multer();
@ -42,14 +40,14 @@ router.post('/aws', (req, res, next) => {
} }
if (req.body.Message.mail && req.body.Message.mail.messageId) { 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) { if (err || !message) {
return; return;
} }
switch (req.body.Message.notificationType) { switch (req.body.Message.notificationType) {
case 'Bounce': 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) { if (err) {
log.error('AWS', 'Failed updating message: %s', err.stack); log.error('AWS', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -59,7 +57,7 @@ router.post('/aws', (req, res, next) => {
break; break;
case 'Complaint': case 'Complaint':
if (req.body.Message.complaint) { if (req.body.Message.complaint) {
updateMessage(message, 'complained', true, (err, updated) => { campaigns.updateMessage(message, 'complained', true, (err, updated) => {
if (err) { if (err) {
log.error('AWS', 'Failed updating message: %s', err.stack); log.error('AWS', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -105,7 +103,7 @@ router.post('/sparkpost', (req, res, next) => {
return processEvents(); return processEvents();
} }
getMessage(evt.campaign_id, (err, message) => { campaigns.findMailByCampaign(evt.campaign_id, (err, message) => {
if (err) { if (err) {
return next(err); return next(err);
} }
@ -117,7 +115,7 @@ router.post('/sparkpost', (req, res, next) => {
switch (evt.type) { switch (evt.type) {
case 'bounce': case 'bounce':
// https://support.sparkpost.com/customer/portal/articles/1929896 // 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) { if (err) {
log.error('Sparkpost', 'Failed updating message: %s', err.stack); log.error('Sparkpost', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -126,7 +124,7 @@ router.post('/sparkpost', (req, res, next) => {
return processEvents(); return processEvents();
}); });
case 'spam_complaint': case 'spam_complaint':
return updateMessage(message, 'complained', true, (err, updated) => { return campaigns.updateMessage(message, 'complained', true, (err, updated) => {
if (err) { if (err) {
log.error('Sparkpost', 'Failed updating message: %s', err.stack); log.error('Sparkpost', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -135,7 +133,7 @@ router.post('/sparkpost', (req, res, next) => {
return processEvents(); return processEvents();
}); });
case 'link_unsubscribe': case 'link_unsubscribe':
return updateMessage(message, 'unsubscribed', true, (err, updated) => { return campaigns.updateMessage(message, 'unsubscribed', true, (err, updated) => {
if (err) { if (err) {
log.error('Sparkpost', 'Failed updating message: %s', err.stack); log.error('Sparkpost', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -171,7 +169,7 @@ router.post('/sendgrid', (req, res, next) => {
return processEvents(); return processEvents();
} }
getMessage(evt.campaign_id, (err, message) => { campaigns.findMailByCampaign(evt.campaign_id, (err, message) => {
if (err) { if (err) {
return next(err); return next(err);
} }
@ -183,7 +181,7 @@ router.post('/sendgrid', (req, res, next) => {
switch (evt.event) { switch (evt.event) {
case 'bounce': case 'bounce':
// https://support.sparkpost.com/customer/portal/articles/1929896 // 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) { if (err) {
log.error('Sendgrid', 'Failed updating message: %s', err.stack); log.error('Sendgrid', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -192,7 +190,7 @@ router.post('/sendgrid', (req, res, next) => {
return processEvents(); return processEvents();
}); });
case 'spamreport': case 'spamreport':
return updateMessage(message, 'complained', true, (err, updated) => { return campaigns.updateMessage(message, 'complained', true, (err, updated) => {
if (err) { if (err) {
log.error('Sendgrid', 'Failed updating message: %s', err.stack); log.error('Sendgrid', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -202,7 +200,7 @@ router.post('/sendgrid', (req, res, next) => {
}); });
case 'group_unsubscribe': case 'group_unsubscribe':
case 'unsubscribe': case 'unsubscribe':
return updateMessage(message, 'unsubscribed', true, (err, updated) => { return campaigns.updateMessage(message, 'unsubscribed', true, (err, updated) => {
if (err) { if (err) {
log.error('Sendgrid', 'Failed updating message: %s', err.stack); log.error('Sendgrid', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -222,14 +220,14 @@ router.post('/sendgrid', (req, res, next) => {
router.post('/mailgun', uploads.any(), (req, res) => { router.post('/mailgun', uploads.any(), (req, res) => {
let evt = req.body; 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) { if (err || !message) {
return; return;
} }
switch (evt.event) { switch (evt.event) {
case 'bounced': case 'bounced':
return updateMessage(message, 'bounced', true, (err, updated) => { return campaigns.updateMessage(message, 'bounced', true, (err, updated) => {
if (err) { if (err) {
log.error('Mailgun', 'Failed updating message: %s', err.stack); log.error('Mailgun', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -237,7 +235,7 @@ router.post('/mailgun', uploads.any(), (req, res) => {
} }
}); });
case 'complained': case 'complained':
return updateMessage(message, 'complained', true, (err, updated) => { return campaigns.updateMessage(message, 'complained', true, (err, updated) => {
if (err) { if (err) {
log.error('Mailgun', 'Failed updating message: %s', err.stack); log.error('Mailgun', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -245,7 +243,7 @@ router.post('/mailgun', uploads.any(), (req, res) => {
} }
}); });
case 'unsubscribed': case 'unsubscribed':
return updateMessage(message, 'unsubscribed', true, (err, updated) => { return campaigns.updateMessage(message, 'unsubscribed', true, (err, updated) => {
if (err) { if (err) {
log.error('Mailgun', 'Failed updating message: %s', err.stack); log.error('Mailgun', 'Failed updating message: %s', err.stack);
} else if (updated) { } else if (updated) {
@ -262,102 +260,3 @@ router.post('/mailgun', uploads.any(), (req, res) => {
}); });
module.exports = router; 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);
}
});
});
});
}

View file

@ -1,7 +1,7 @@
'use strict'; 'use strict';
let log = require('npmlog'); let log = require('npmlog');
let config = require('config');
let db = require('../lib/db'); let db = require('../lib/db');
let tools = require('../lib/tools'); let tools = require('../lib/tools');
let mailer = require('../lib/mailer'); let mailer = require('../lib/mailer');
@ -111,11 +111,13 @@ function formatMessage(message, callback) {
return callback(new Error('List not found')); return callback(new Error('List not found'));
} }
settings.get('serviceUrl', (err, serviceUrl) => { settings.list(['serviceUrl', 'verpUse', 'verpHostname'], (err, configItems) => {
if (err) { if (err) {
return callback(err); return callback(err);
} }
let useVerp = config.verp.enabled && configItems.verpUse && configItems.verpHostname;
fields.list(list.id, (err, fieldList) => { fields.list(list.id, (err, fieldList) => {
if (err) { if (err) {
return callback(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) { if (err) {
return callback(err); return callback(err);
} }
@ -157,43 +159,52 @@ function formatMessage(message, callback) {
return prefix + 'cid:' + cid; return prefix + 'cid:' + cid;
}); });
let campaignAddress = [campaign.cid, list.cid, message.subscription.cid].join('.');
return callback(null, { return callback(null, {
from: { from: {
name: campaign.from, name: campaign.from,
address: campaign.address address: campaign.address
}, },
xMailer: 'Mailtrain Mailer (+http://mailtrain.org)', xMailer: 'Mailtrain Mailer (+https://mailtrain.org)',
to: { to: {
name: [].concat(message.subscription.firstName || []).concat(message.subscription.lastName || []).join(' '), name: [].concat(message.subscription.firstName || []).concat(message.subscription.lastName || []).join(' '),
address: message.subscription.email address: message.subscription.email
}, },
sender: useVerp ? campaignAddress + '@' + configItems.verpHostname : false,
envelope: useVerp ? {
from: campaignAddress + '@' + configItems.verpHostname,
to: message.subscription.email
} : false,
headers: { headers: {
'x-fbl': [campaign.cid, list.cid, message.subscription.cid].join('.'), 'x-fbl': campaignAddress,
// custom header for SparkPost // custom header for SparkPost
'x-msys-api': JSON.stringify({ 'x-msys-api': JSON.stringify({
campaign_id: [campaign.cid, list.cid, message.subscription.cid].join('.') campaign_id: campaignAddress
}), }),
// custom header for SendGrid // custom header for SendGrid
'x-smtpapi': JSON.stringify({ 'x-smtpapi': JSON.stringify({
unique_args: { unique_args: {
campaign_id: [campaign.cid, list.cid, message.subscription.cid].join('.') campaign_id: campaignAddress
} }
}), }),
// custom header for Mailgun // custom header for Mailgun
'x-mailgun-variables': JSON.stringify({ 'x-mailgun-variables': JSON.stringify({
campaign_id: [campaign.cid, list.cid, message.subscription.cid].join('.') campaign_id: campaignAddress
}), }),
'List-ID': { 'List-ID': {
prepared: true, 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: { 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), subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject),
html: tools.formatMessage(serviceUrl, campaign, list, message.subscription, html), html: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html),
text: tools.formatMessage(serviceUrl, campaign, list, message.subscription, campaign.text), text: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.text),
attachments attachments
}); });

View file

@ -4,7 +4,6 @@ let log = require('npmlog');
let config = require('config'); let config = require('config');
let crypto = require('crypto'); let crypto = require('crypto');
// Replace '../lib/smtp-server' with 'smtp-server' when running this script outside this directory
let SMTPServer = require('smtp-server').SMTPServer; let SMTPServer = require('smtp-server').SMTPServer;
// Setup server // Setup server
@ -98,7 +97,7 @@ server.on('error', err => {
}); });
if (config.testserver.enabled) { 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); log.info('TESTSERV', 'Server listening on port %s', config.testserver.port);
}); });
} }

103
services/verp-server.js Normal file
View 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);
});
}

View file

@ -122,6 +122,16 @@
</div> </div>
</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"> <div class="form-group">
<label for="smtp-user" class="col-sm-2 control-label">Username</label> <label for="smtp-user" class="col-sm-2 control-label">Username</label>
<div class="col-sm-10"> <div class="col-sm-10">
@ -145,6 +155,17 @@
<legend> <legend>
Advanced SMTP settings Advanced SMTP settings
</legend> </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"> <div class="form-group">
<label for="smtp-max-connections" class="col-sm-2 control-label">Max connections</label> <label for="smtp-max-connections" class="col-sm-2 control-label">Max connections</label>
<div class="col-sm-4"> <div class="col-sm-4">
@ -172,6 +193,53 @@
</div> </div>
</fieldset> </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 /> <hr />
<div class="form-group"> <div class="form-group">