From e5e71e0407c4d674b0fb13cfd40b812395319fff Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Sun, 10 Apr 2016 20:26:20 -0700 Subject: [PATCH] Added VERP support --- .gitignore | 1 + README.md | 21 +++- config/default.toml | 8 ++ index.js | 6 +- lib/mailer.js | 9 +- lib/models/campaigns.js | 102 +++++++++++++++- package.json | 7 +- routes/settings.js | 26 ++-- routes/webhooks.js | 131 +++------------------ services/sender.js | 37 ++++-- services/{testserver.js => test-server.js} | 3 +- services/verp-server.js | 103 ++++++++++++++++ views/settings.hbs | 68 +++++++++++ 13 files changed, 374 insertions(+), 148 deletions(-) rename services/{testserver.js => test-server.js} (95%) create mode 100644 services/verp-server.js diff --git a/.gitignore b/.gitignore index 28a134bc..8b8d217b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules npm-debug.log .DS_Store development.toml +dump.rdb diff --git a/README.md b/README.md index 6590e23f..b63d4ed0 100644 --- a/README.md +++ b/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` diff --git a/config/default.toml b/config/default.toml index 2ad2da53..14b26037 100644 --- a/config/default.toml +++ b/config/default.toml @@ -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" diff --git a/index.js b/index.js index 0e32dc0e..5cf353b2 100644 --- a/index.js +++ b/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 }); diff --git a/lib/mailer.js b/lib/mailer.js index b5eb38af..dce34cb4 100644 --- a/lib/mailer.js +++ b/lib/mailer.js @@ -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); diff --git a/lib/models/campaigns.js b/lib/models/campaigns.js index 92b55fac..c5e0df85 100644 --- a/lib/models/campaigns.js +++ b/lib/models/campaigns.js @@ -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) => { diff --git a/package.json b/package.json index 36b48a6d..6aff43fc 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -15,8 +15,8 @@ "author": "Andris Reinman", "license": "GPL-3.0", "homepage": "http://mailtrain.org", - "engines" : { - "node" : ">=5.0.0" + "engines": { + "node": ">=5.0.0" }, "devDependencies": { "grunt": "^1.0.1", @@ -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", diff --git a/routes/settings.js b/routes/settings.js index c2481e92..89e32d49 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -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'); - values.push(''); - } + + // 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 } }); diff --git a/routes/webhooks.js b/routes/webhooks.js index b4624d09..331b95f6 100644 --- a/routes/webhooks.js +++ b/routes/webhooks.js @@ -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); - } - }); - }); - - }); -} diff --git a/services/sender.js b/services/sender.js index 177395a9..36d9195d 100644 --- a/services/sender.js +++ b/services/sender.js @@ -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 }); diff --git a/services/testserver.js b/services/test-server.js similarity index 95% rename from services/testserver.js rename to services/test-server.js index 25767806..a9bbed01 100644 --- a/services/testserver.js +++ b/services/test-server.js @@ -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); }); } diff --git a/services/verp-server.js b/services/verp-server.js new file mode 100644 index 00000000..511d5c9e --- /dev/null +++ b/services/verp-server.js @@ -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); + }); +} diff --git a/views/settings.hbs b/views/settings.hbs index 5e33589c..1c296a81 100644 --- a/views/settings.hbs +++ b/views/settings.hbs @@ -122,6 +122,16 @@ +
+
+
+ +
+
+
+
@@ -145,6 +155,17 @@ Advanced SMTP settings + +
+
+
+ +
+
+
+
@@ -172,6 +193,53 @@
+
+ + VERP bounce handling + + +

+ Mailtrain is able to use VERP 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. +

+ +

+ 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 someuser@{{verpHostname}} then the email should end up to this server. +

+ +

+ 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. +

+ + {{#if verpEnabled}} + +
+
+
+ +
+
+
+ +
+ +
+ + 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 +
+
+ + {{else}} +
+
+

VERP bounce handling server is not enabled. Modify your server configuration file and restart server to enable it

+
+
+ {{/if}} +
+