Merge pull request #16 from andris9/v1.1

V1.1
This commit is contained in:
Andris Reinman 2016-04-11 21:35:48 -07:00
commit b3164f0867
30 changed files with 520 additions and 209 deletions

1
.gitignore vendored
View file

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

View file

@ -31,7 +31,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/)
@ -39,6 +39,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`
@ -56,6 +75,8 @@ Mailtrain uses webhooks integration to detect bounces and spam complaints. Curre
* **SendGrid** use `http://domain/webhooks/sendgrid` as the webhook URL for bounces and complaints
* **Mailgun** use `http://domain/webhooks/mailgun` as the webhook URL for bounces and complaints
Additionally Mailtrain (v1.1+) is able to use VERP-based bounce handling. This would require to have a compatible SMTP relay (the services mentioned above strip out or block VERP addresses in the SMTP envelope) and you also need to set up special MX DNS name that points to your Mailtrain installation server.
## License
**GPL-V3.0**

10
app.js
View file

@ -9,7 +9,6 @@ let path = require('path');
let favicon = require('serve-favicon');
let logger = require('morgan');
let cookieParser = require('cookie-parser');
let csp = require('content-security-policy');
let session = require('express-session');
let RedisStore = require('connect-redis')(session);
let flash = require('connect-flash');
@ -51,7 +50,7 @@ app.disable('x-powered-by');
* in a situation where we consume a flash messages but then comes a redirect
* and the message is never displayed
*/
hbs.registerHelper('flash_messages', function () { // eslint-disable-line prefer-arrow-callback
hbs.registerHelper('flash_messages', function() { // eslint-disable-line prefer-arrow-callback
if (typeof this.flash !== 'function') { // eslint-disable-line no-invalid-this
return '';
}
@ -114,13 +113,6 @@ app.use(session({
}));
app.use(flash());
// Content-Security-Policy headers
let cspOptions = Object.create(csp.STARTER_OPTIONS);
cspOptions['style-src'] = '\'self\' \'unsafe-inline\' https://fonts.googleapis.com';
cspOptions['img-src'] = '\'self\' data:';
cspOptions['font-src'] = '\'self\' https://fonts.gstatic.com';
app.use(csp.getCSP(cspOptions));
app.use(bodyParser.urlencoded({
extended: true
}));

View file

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

View file

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

View file

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

View file

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

View file

@ -202,6 +202,7 @@ module.exports.subscribe = (cid, optInIp, callback) => {
connection.release();
callback(null, {
list: listId,
cid,
email
});
});

View file

@ -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,11 +27,11 @@
"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",
"connect-redis": "^3.0.2",
"content-security-policy": "^0.2.0",
"cookie-parser": "^1.4.1",
"csurf": "^1.8.3",
"csv-parse": "^1.0.4",

11
public/ace/ace.js Normal file

File diff suppressed because one or more lines are too long

1
public/ace/mode-html.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
ace.define("ace/theme/chrome",["require","exports","module","ace/lib/dom"],function(e,t,n){t.isDark=!1,t.cssClass="ace-chrome",t.cssText='.ace-chrome .ace_gutter {background: #ebebeb;color: #333;overflow : hidden;}.ace-chrome .ace_print-margin {width: 1px;background: #e8e8e8;}.ace-chrome {background-color: #FFFFFF;color: black;}.ace-chrome .ace_cursor {color: black;}.ace-chrome .ace_invisible {color: rgb(191, 191, 191);}.ace-chrome .ace_constant.ace_buildin {color: rgb(88, 72, 246);}.ace-chrome .ace_constant.ace_language {color: rgb(88, 92, 246);}.ace-chrome .ace_constant.ace_library {color: rgb(6, 150, 14);}.ace-chrome .ace_invalid {background-color: rgb(153, 0, 0);color: white;}.ace-chrome .ace_fold {}.ace-chrome .ace_support.ace_function {color: rgb(60, 76, 114);}.ace-chrome .ace_support.ace_constant {color: rgb(6, 150, 14);}.ace-chrome .ace_support.ace_type,.ace-chrome .ace_support.ace_class.ace-chrome .ace_support.ace_other {color: rgb(109, 121, 222);}.ace-chrome .ace_variable.ace_parameter {font-style:italic;color:#FD971F;}.ace-chrome .ace_keyword.ace_operator {color: rgb(104, 118, 135);}.ace-chrome .ace_comment {color: #236e24;}.ace-chrome .ace_comment.ace_doc {color: #236e24;}.ace-chrome .ace_comment.ace_doc.ace_tag {color: #236e24;}.ace-chrome .ace_constant.ace_numeric {color: rgb(0, 0, 205);}.ace-chrome .ace_variable {color: rgb(49, 132, 149);}.ace-chrome .ace_xml-pe {color: rgb(104, 104, 91);}.ace-chrome .ace_entity.ace_name.ace_function {color: #0000A2;}.ace-chrome .ace_heading {color: rgb(12, 7, 255);}.ace-chrome .ace_list {color:rgb(185, 6, 144);}.ace-chrome .ace_marker-layer .ace_selection {background: rgb(181, 213, 255);}.ace-chrome .ace_marker-layer .ace_step {background: rgb(252, 255, 0);}.ace-chrome .ace_marker-layer .ace_stack {background: rgb(164, 229, 101);}.ace-chrome .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid rgb(192, 192, 192);}.ace-chrome .ace_marker-layer .ace_active-line {background: rgba(0, 0, 0, 0.07);}.ace-chrome .ace_gutter-active-line {background-color : #dcdcdc;}.ace-chrome .ace_marker-layer .ace_selected-word {background: rgb(250, 250, 255);border: 1px solid rgb(200, 200, 250);}.ace-chrome .ace_storage,.ace-chrome .ace_keyword,.ace-chrome .ace_meta.ace_tag {color: rgb(147, 15, 128);}.ace-chrome .ace_string.ace_regex {color: rgb(255, 0, 0)}.ace-chrome .ace_string {color: #1A1AA6;}.ace-chrome .ace_entity.ace_other.ace_attribute-name {color: #994409;}.ace-chrome .ace_indent-guide {background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==") right repeat-y;}';var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass)})

File diff suppressed because one or more lines are too long

View file

@ -27,3 +27,7 @@ body {
div.jumbotron{
margin-top: -21px;
}
.code-editor {
height: 400px;
}

View file

@ -1,5 +1,6 @@
/* eslint-env browser */
/* globals $: false */
/* eslint no-invalid-this: 0, no-var: 0, prefer-arrow-callback: 0 */
/* globals $: false, ace: false */
'use strict';
@ -7,3 +8,16 @@ $('.summernote').summernote({
height: 400,
tabsize: 2
});
$('div.code-editor').each(function() {
var editor = ace.edit(this);
var textarea = document.querySelector('input[name=html]');
editor.setTheme('ace/theme/chrome');
editor.getSession().setMode('ace/mode/html');
editor.getSession().setUseWrapMode(true);
editor.getSession().setUseSoftTabs(true);
editor.getSession().on('change', function() {
textarea.value = editor.getSession().getValue();
});
textarea.value = editor.getSession().getValue();
});

View file

@ -130,40 +130,47 @@ router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) =
});
});
router.get('/edit/:id', passport.csrfProtection, (req, res) => {
router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
campaigns.get(req.params.id, false, (err, campaign) => {
if (err || !campaign) {
req.flash('danger', err && err.message || err || 'Could not find campaign with specified ID');
return res.redirect('/campaigns');
}
lists.quicklist((err, listItems) => {
settings.list(['disableWysiwyg'], (err, configItems) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
return next(err);
}
if (Number(campaign.list)) {
listItems.forEach(list => {
list.segments.forEach(segment => {
if (segment.id === campaign.segment) {
segment.selected = true;
lists.quicklist((err, listItems) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
if (Number(campaign.list)) {
listItems.forEach(list => {
list.segments.forEach(segment => {
if (segment.id === campaign.segment) {
segment.selected = true;
}
});
if (list.id === campaign.list && !campaign.segment) {
list.selected = true;
}
});
if (list.id === campaign.list && !campaign.segment) {
list.selected = true;
}
});
}
}
campaign.csrfToken = req.csrfToken();
campaign.listItems = listItems;
campaign.useEditor = true;
campaign.csrfToken = req.csrfToken();
campaign.listItems = listItems;
campaign.useEditor = true;
campaign.showGeneral = req.query.tab === 'general' || !req.query.tab;
campaign.showTemplate = req.query.tab === 'template';
campaign.disableWysiwyg = configItems.disableWysiwyg;
campaign.showGeneral = req.query.tab === 'general' || !req.query.tab;
campaign.showTemplate = req.query.tab === 'template';
res.render('campaigns/edit', campaign);
res.render('campaigns/edit', campaign);
});
});
});
});

View file

@ -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', 'disable_wysiwyg'];
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', 'disable_wysiwyg'].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
}
});

View file

@ -42,7 +42,8 @@ router.get('/subscribe/:cid', (req, res, next) => {
res.render('subscription/subscribed', {
title: list.name,
layout: 'subscription/layout',
homepage: configItems.defaultHomepage || configItems.serviceUrl
homepage: configItems.defaultHomepage || configItems.serviceUrl,
preferences: '/subscription/' + list.cid + '/manage/' + subscription.cid
});
});
});

View file

@ -47,7 +47,7 @@ router.get('/create', passport.csrfProtection, (req, res, next) => {
data.csrfToken = req.csrfToken();
data.useEditor = true;
settings.list(['defaultPostaddress', 'defaultSender'], (err, configItems) => {
settings.list(['defaultPostaddress', 'defaultSender', 'disableWysiwyg'], (err, configItems) => {
if (err) {
return next(err);
}
@ -64,6 +64,7 @@ router.get('/create', passport.csrfProtection, (req, res, next) => {
data.html = data.html || rendererHtml(configItems);
data.text = data.text || rendererText(configItems);
data.disableWysiwyg = configItems.disableWysiwyg;
res.render('templates/create', data);
});
});
@ -81,15 +82,21 @@ router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) =
});
});
router.get('/edit/:id', passport.csrfProtection, (req, res) => {
router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
templates.get(req.params.id, (err, template) => {
if (err || !template) {
req.flash('danger', err && err.message || err || 'Could not find template with specified ID');
return res.redirect('/templates');
}
template.csrfToken = req.csrfToken();
template.useEditor = true;
res.render('templates/edit', template);
settings.list(['disableWysiwyg'], (err, configItems) => {
if (err) {
return next(err);
}
template.csrfToken = req.csrfToken();
template.useEditor = true;
template.disableWysiwyg = configItems.disableWysiwyg;
res.render('templates/edit', template);
});
});
});

View file

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

View file

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

View file

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

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

@ -145,14 +145,19 @@
<div class="form-group">
<label for="template-html" class="col-sm-2 control-label">Template content (HTML)</label>
<div class="col-sm-10">
<textarea class="form-control summernote" if="template-html" name="html" rows="8">{{html}}</textarea>
{{#if disableWysiwyg}}
<div class="code-editor" id="template-html">{{html}}</div>
<input type="hidden" name="html">
{{else}}
<textarea class="form-control summernote" id="template-html" name="html" rows="8">{{html}}</textarea>
{{/if}}
</div>
</div>
<div class="form-group">
<label for="template-text" class="col-sm-2 control-label">Template content (plaintext)</label>
<div class="col-sm-10">
<textarea class="form-control" if="template-text" name="text" rows="10">{{text}}</textarea>
<textarea class="form-control" id="template-text" name="text" rows="10">{{text}}</textarea>
</div>
</div>
</fieldset>

View file

@ -1,19 +1,32 @@
<p>Hey [FIRST_NAME/Customer],</p>
<!doctype html>
<html>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed varius, leo a ullamcorper feugiat, ante purus sodales justo, a faucibus libero lacus a est.</p>
<head>
<meta charset="utf-8">
</head>
<p>Sed varius, leo a ullamcorper feugiat, ante purus sodales justo, a faucibus libero lacus a est. Aenean at mollis ipsum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed varius, leo a ullamcorper feugiat, ante purus sodales justo, a faucibus
libero lacus a est.</p>
<body>
<p>Cheers,
<br/> {{defaultSender}}
</p>
<p>
{{defaultPostaddress}}
<br/>
<a href="[LINK_PREFERENCES]" style="color: #666666; text-decoration: none;">Preferences</a>
<span style="color: #444444;">&nbsp;&nbsp;|&nbsp;&nbsp;</span>
<a href="[LINK_UNSUBSCRIBE]" style="color: #666666; text-decoration: none;">Unsubscribe</a>
<span style="color: #444444;">&nbsp;&nbsp;|&nbsp;&nbsp;</span>
<a href="[LINK_BROWSER]" style="color: #666666; text-decoration: none;">View this email in your browser</a>
</p>
<p>Hey [FIRST_NAME/Customer],</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed varius, leo a ullamcorper feugiat, ante purus sodales justo, a faucibus libero lacus a est.</p>
<p>Sed varius, leo a ullamcorper feugiat, ante purus sodales justo, a faucibus libero lacus a est. Aenean at mollis ipsum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed varius, leo a ullamcorper feugiat, ante purus sodales justo, a faucibus
libero lacus a est.</p>
<p>Cheers,
<br/> {{defaultSender}}
</p>
<p>
{{defaultPostaddress}}
<br/>
<a href="[LINK_PREFERENCES]" style="color: #666666; text-decoration: none;">Preferences</a>
<span style="color: #444444;">&nbsp;&nbsp;|&nbsp;&nbsp;</span>
<a href="[LINK_UNSUBSCRIBE]" style="color: #666666; text-decoration: none;">Unsubscribe</a>
<span style="color: #444444;">&nbsp;&nbsp;|&nbsp;&nbsp;</span>
<a href="[LINK_BROWSER]" style="color: #666666; text-decoration: none;">View this email in your browser</a>
</p>
</body>
</html>

View file

@ -139,6 +139,7 @@
<script src="/javascript/tables.js"></script>
{{#if useEditor}}
<script src="/ace/ace.js" type="text/javascript" charset="utf-8"></script>
<script src="/summernote/summernote.min.js"></script>
<script src="/javascript/editor.js"></script>
{{/if}}

View file

@ -36,6 +36,16 @@
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-xs-4">
<div class="checkbox">
<label>
<input type="checkbox" name="disable-wysiwyg" {{#if disableWysiwyg}} checked {{/if}}> Disable WYSIWYG editor
</label>
</div>
</div>
</div>
</fieldset>
<fieldset>
@ -122,6 +132,16 @@
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-xs-4">
<div class="checkbox">
<label>
<input type="checkbox" name="smtp-disable-auth" {{#if smtpDisableAuth}} checked {{/if}}> Disable SMTP authentication
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="smtp-user" class="col-sm-2 control-label">Username</label>
<div class="col-sm-10">
@ -145,6 +165,17 @@
<legend>
Advanced SMTP settings
</legend>
<div class="form-group">
<div class="col-sm-offset-2 col-xs-4">
<div class="checkbox">
<label>
<input type="checkbox" name="smtp-self-signed" {{#if smtpSelfSigned}} checked {{/if}}> Allow self-signed certificates
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="smtp-max-connections" class="col-sm-2 control-label">Max connections</label>
<div class="col-sm-4">
@ -172,6 +203,54 @@
</div>
</fieldset>
<fieldset>
<legend>
VERP bounce handling
</legend>
<p class="text-info">
Mailtrain is able to use <a href="https://en.wikipedia.org/wiki/Variable_envelope_return_path">VERP</a> based routing to detect bounces. In this case the message is sent to the recipient using a custom VERP address as the return path of the
message. If the message is not accepted a bounce email is sent to this special VERP address and thus a bounce is detected.
</p>
<p class="text-info">
To get VERP working you need to set up a DNS MX record that points to your Mailtrain hostname. You must also ensure that Mailtrain VERP interface is available from port 25 of your server (port 25 usually requires root user privileges). This way if anyone
tries to send email to <code>someuser@{{verpHostname}}</code> then the email should end up to this server.
</p>
<p class="text-warning">
VERP usually only works if you are using your own SMTP server. Regural relay services (SES, SparkPost, Gmail etc.) tend to remove the VERP address from the message.
</p>
{{#if verpEnabled}}
<div class="form-group">
<div class="col-sm-offset-2 col-xs-4">
<div class="checkbox">
<label>
<input type="checkbox" name="verp-use" {{#if verpUse}} checked {{/if}}> Use VERP to catch bounces
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="verp-hostname" class="col-sm-2 control-label">Server hostname</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="verp-hostname" id="verp-hostname" placeholder="The VERP server hostname, eg. bounces.example.com" value="{{verpHostname}}">
<span class="help-block">VERP bounce handling server hostname. This hostname is used in the SMTP envelope FROM address and the MX DNS records should point to this server</span>
</div>
</div>
{{else}}
<div class="form-group">
<div class="col-sm-10">
<p class="form-control-static">VERP bounce handling server is not enabled. Modify your server configuration file and restart server to enable it</p>
</div>
</div>
{{/if}}
</fieldset>
<hr />
<div class="form-group">

View file

@ -6,6 +6,10 @@
<p>
<a class="btn btn-primary" href="{{homepage}}" role="button">
<span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span> return to our website
<span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span> continue to our website
</a>
or
<a class="btn btn-primary" href="{{preferences}}" role="button">
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> manage you preferences
</a>
</p>

View file

@ -57,21 +57,26 @@
<div class="form-group">
<label for="template-html" class="col-sm-2 control-label">Template content (HTML)</label>
<div class="col-sm-10">
<textarea class="form-control summernote" if="template-html" name="html" rows="8">{{html}}</textarea>
{{#if disableWysiwyg}}
<div class="code-editor" id="template-html">{{html}}</div>
<input type="hidden" name="html">
{{else}}
<textarea class="form-control summernote" id="template-html" name="html" rows="8">{{html}}</textarea>
{{/if}}
</div>
</div>
<div class="form-group">
<label for="template-text" class="col-sm-2 control-label">Template content (plaintext)</label>
<div class="col-sm-10">
<textarea class="form-control" if="template-text" name="text" rows="10">{{text}}</textarea>
<textarea class="form-control" id="template-text" name="text" rows="10">{{text}}</textarea>
</div>
</div>
<div class="form-group">
<label for="template-description" class="col-sm-2 control-label">Description</label>
<div class="col-sm-10">
<textarea class="form-control" if="template-description" name="description" rows="3" placeholder="Optional comments about this template">{{description}}</textarea>
<textarea class="form-control" id="template-description" name="description" rows="3" placeholder="Optional comments about this template">{{description}}</textarea>
<span class="help-block">HTML is allowed</span>
</div>
</div>

View file

@ -63,14 +63,19 @@
<div class="form-group">
<label for="template-html" class="col-sm-2 control-label">Template content (HTML)</label>
<div class="col-sm-10">
<textarea class="form-control summernote" if="template-html" name="html" rows="8">{{html}}</textarea>
{{#if disableWysiwyg}}
<div class="code-editor" id="template-html">{{html}}</div>
<input type="hidden" name="html">
{{else}}
<textarea class="form-control summernote" id="template-html" name="html" rows="8">{{html}}</textarea>
{{/if}}
</div>
</div>
<div class="form-group">
<label for="template-text" class="col-sm-2 control-label">Template content (plaintext)</label>
<div class="col-sm-10">
<textarea class="form-control" if="template-text" name="text" rows="10">{{text}}</textarea>
<textarea class="form-control" id="template-text" name="text" rows="10">{{text}}</textarea>
</div>
</div>