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