diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 53590b5d..68bd41f7 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,10 @@ # Changelog +## 1.11.0 2016-05-31 + + * Retry transactional mail if failed with soft error (4xx) + * New feature to preview campaigns using selected test users + ## 1.10.1 2016-05-26 * Fix a bug with SMTP transport instance where campaign sending stalled until server was restarted diff --git a/lib/mailer.js b/lib/mailer.js index fe27f74a..331ddcef 100644 --- a/lib/mailer.js +++ b/lib/mailer.js @@ -72,7 +72,24 @@ module.exports.sendMail = (mail, template, callback) => { }); } - module.exports.transport.sendMail(mail, callback); + let tryCount = 0; + let trySend = () => { + tryCount++; + + module.exports.transport.sendMail(mail, (err, info) => { + if (err) { + log.error('Mail', err.stack); + if (err.responseCode && err.responseCode >= 400 && err.responseCode < 500 && tryCount <= 5) { + // temporary error, try again + log.verbose('Mail', 'Retrying after %s sec. ...', tryCount); + return setTimeout(trySend, tryCount * 1000); + } + return callback(err); + } + return callback(null, info); + }); + }; + setImmediate(trySend); }); }); }); diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index 3f5d2dea..f92392bb 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -40,6 +40,44 @@ module.exports.list = (listId, start, limit, callback) => { }); }; +module.exports.listTestUsers = (listId, callback) => { + listId = Number(listId) || 0; + + if (listId < 1) { + return callback(new Error('Missing List ID')); + } + + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + connection.query('SELECT id, cid, email, first_name, last_name FROM `subscription__' + listId + '` WHERE is_test=1 LIMIT 100', (err, rows) => { + connection.release(); + if (err) { + return callback(err); + } + + if (!rows || !rows.length) { + return callback(null, []); + } + + let subscribers = rows.map(subscriber => { + subscriber = tools.convertKeys(subscriber); + let fullName = [].concat(subscriber.firstName || []).concat(subscriber.lastName || []).join(' '); + if (fullName) { + subscriber.displayName = fullName + ' <' + subscriber.email + '>'; + } else { + subscriber.displayName = subscriber.email; + } + return subscriber; + }); + return callback(null, subscribers); + }); + }); +}; + + module.exports.filter = (listId, request, columns, segmentId, callback) => { listId = Number(listId) || 0; segmentId = Number(segmentId) || 0; @@ -291,13 +329,16 @@ module.exports.insert = (listId, meta, subscription, callback) => { let keys = []; let values = []; - let allowedKeys = ['first_name', 'last_name', 'tz']; + let allowedKeys = ['first_name', 'last_name', 'tz', 'is_test']; Object.keys(subscription).forEach(key => { let value = subscription[key]; key = tools.toDbKey(key); if (key === 'tz') { value = (value || '').toString().toLowerCase().trim(); } + if (key === 'is_test') { + value = value ? '1' : '0'; + } if (allowedKeys.indexOf(key) >= 0) { keys.push(key); values.push(value); @@ -551,7 +592,7 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => { return callback(err); } - let allowedKeys = ['first_name', 'last_name', 'tz']; + let allowedKeys = ['first_name', 'last_name', 'tz', 'is_test']; if (allowEmail) { allowedKeys.unshift('email'); diff --git a/meta.json b/meta.json index bca525e4..bf813870 100644 --- a/meta.json +++ b/meta.json @@ -1,3 +1,3 @@ { - "schemaVersion": 13 + "schemaVersion": 14 } diff --git a/package.json b/package.json index f9c26462..9c7a20d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mailtrain", "private": true, - "version": "1.10.1", + "version": "1.11.0", "description": "Self hosted email newsletter app", "main": "index.js", "scripts": { @@ -36,8 +36,8 @@ "config": "^1.20.4", "connect-flash": "^0.1.1", "connect-redis": "^3.0.2", - "cookie-parser": "^1.4.2", - "csurf": "^1.8.3", + "cookie-parser": "^1.4.3", + "csurf": "^1.9.0", "csv-parse": "^1.1.0", "escape-html": "^1.0.3", "express": "^4.13.4", @@ -51,7 +51,7 @@ "humanize": "0.0.9", "is-url": "^1.2.1", "isemail": "^2.1.2", - "jsdom": "^9.2.0", + "jsdom": "^9.2.1", "juice": "^2.0.0", "moment-timezone": "^0.5.4", "morgan": "^1.7.0", diff --git a/routes/archive.js b/routes/archive.js index 3a7f3897..c1f19275 100644 --- a/routes/archive.js +++ b/routes/archive.js @@ -59,7 +59,7 @@ router.get('/:campaign/:list/:subscription', (req, res, next) => { return res.redirect('/'); } - if (!mail) { + if (!mail && !req.user) { err = new Error('Not Found'); err.status = 404; return next(err); diff --git a/routes/campaigns.js b/routes/campaigns.js index 2ede36d9..e93e24dd 100644 --- a/routes/campaigns.js +++ b/routes/campaigns.js @@ -6,6 +6,7 @@ let lists = require('../lib/models/lists'); let fields = require('../lib/models/fields'); let templates = require('../lib/models/templates'); let campaigns = require('../lib/models/campaigns'); +let subscriptions = require('../lib/models/subscriptions'); let settings = require('../lib/models/settings'); let tools = require('../lib/tools'); let striptags = require('striptags'); @@ -306,51 +307,70 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => { campaign.csrfToken = req.csrfToken(); campaign.list = list; - campaign.isIdling = campaign.status === 1; - campaign.isSending = campaign.status === 2; - campaign.isFinished = campaign.status === 3; - campaign.isPaused = campaign.status === 4; - campaign.isInactive = campaign.status === 5; - campaign.isActive = campaign.status === 6; - - campaign.isNormal = campaign.type === 1 || campaign.type === 3; - campaign.isRss = campaign.type === 2; - - campaign.isScheduled = campaign.scheduled && campaign.scheduled > new Date(); - - // show only messages that weren't bounced as delivered - campaign.delivered = campaign.delivered - campaign.bounced; - - campaign.openRate = campaign.delivered ? Math.round((campaign.opened / campaign.delivered) * 10000)/100 : 0; - campaign.clicksRate = campaign.delivered ? Math.round((campaign.clicks / campaign.delivered) * 10000)/100 : 0; - campaign.bounceRate = campaign.delivered ? Math.round((campaign.bounced / campaign.delivered) * 10000)/100 : 0; - campaign.complaintRate = campaign.delivered ? Math.round((campaign.complained / campaign.delivered) * 10000)/100 : 0; - campaign.unsubscribeRate = campaign.delivered ? Math.round((campaign.unsubscribed / campaign.delivered) * 10000)/100 : 0; - - campaigns.getLinks(campaign.id, (err, links) => { - if (err) { - // ignore + subscriptions.listTestUsers(list.id, (err, testUsers) => { + if (err || !testUsers) { + testUsers = []; } - let index = 0; - campaign.links = (links || []).map(link => { - link.index = ++index; - link.totalPercentage = campaign.delivered ? Math.round(((link.clicks / campaign.delivered) * 100) * 1000) / 1000 : 0; - link.relPercentage = campaign.clicks ? Math.round(((link.clicks / campaign.clicks) * 100) * 1000) / 1000 : 0; - link.short = link.url.replace(/^https?:\/\/(www.)?/i, ''); - if (link.short > 63) { - link.short = link.short.substr(0, 60) + '…'; - } - return link; - }); - campaign.showOverview = !req.query.tab || req.query.tab === 'overview'; - campaign.showLinks = req.query.tab === 'links'; - res.render('campaigns/view', campaign); - }); + campaign.testUsers = testUsers; + campaign.isIdling = campaign.status === 1; + campaign.isSending = campaign.status === 2; + campaign.isFinished = campaign.status === 3; + campaign.isPaused = campaign.status === 4; + campaign.isInactive = campaign.status === 5; + campaign.isActive = campaign.status === 6; + + campaign.isNormal = campaign.type === 1 || campaign.type === 3; + campaign.isRss = campaign.type === 2; + + campaign.isScheduled = campaign.scheduled && campaign.scheduled > new Date(); + + // show only messages that weren't bounced as delivered + campaign.delivered = campaign.delivered - campaign.bounced; + + campaign.openRate = campaign.delivered ? Math.round((campaign.opened / campaign.delivered) * 10000) / 100 : 0; + campaign.clicksRate = campaign.delivered ? Math.round((campaign.clicks / campaign.delivered) * 10000) / 100 : 0; + campaign.bounceRate = campaign.delivered ? Math.round((campaign.bounced / campaign.delivered) * 10000) / 100 : 0; + campaign.complaintRate = campaign.delivered ? Math.round((campaign.complained / campaign.delivered) * 10000) / 100 : 0; + campaign.unsubscribeRate = campaign.delivered ? Math.round((campaign.unsubscribed / campaign.delivered) * 10000) / 100 : 0; + + campaigns.getLinks(campaign.id, (err, links) => { + if (err) { + // ignore + } + let index = 0; + campaign.links = (links || []).map(link => { + link.index = ++index; + link.totalPercentage = campaign.delivered ? Math.round(((link.clicks / campaign.delivered) * 100) * 1000) / 1000 : 0; + link.relPercentage = campaign.clicks ? Math.round(((link.clicks / campaign.clicks) * 100) * 1000) / 1000 : 0; + link.short = link.url.replace(/^https?:\/\/(www.)?/i, ''); + if (link.short > 63) { + link.short = link.short.substr(0, 60) + '…'; + } + return link; + }); + campaign.showOverview = !req.query.tab || req.query.tab === 'overview'; + campaign.showLinks = req.query.tab === 'links'; + res.render('campaigns/view', campaign); + }); + }); }); }); }); +router.post('/preview/:id', passport.parseForm, passport.csrfProtection, (req, res) => { + let campaign = req.body.campaign; + let list = req.body.list; + let listId = req.body.listId; + let subscription = req.body.subscriber; + + if (subscription === '_create') { + return res.redirect('/lists/subscription/' + encodeURIComponent(listId) + '/add/?is-test=true'); + } + + res.redirect('/archive/' + encodeURIComponent(campaign) + '/' + encodeURIComponent(list) + '/' + encodeURIComponent(subscription)); +}); + router.get('/opened/:id', passport.csrfProtection, (req, res) => { campaigns.get(req.params.id, true, (err, campaign) => { if (err || !campaign) { diff --git a/routes/lists.js b/routes/lists.js index 6c5c6007..ccf1bed1 100644 --- a/routes/lists.js +++ b/routes/lists.js @@ -410,6 +410,7 @@ router.post('/subscription/delete', passport.parseForm, passport.csrfProtection, }); router.post('/subscription/edit', passport.parseForm, passport.csrfProtection, (req, res) => { + req.body['is-test'] = req.body['is-test'] ? '1' : '0'; subscriptions.update(req.body.list, req.body.cid, req.body, true, (err, updated) => { if (err) { diff --git a/setup/sql/mailtrain.sql b/setup/sql/mailtrain.sql index ecd895f8..09135093 100644 --- a/setup/sql/mailtrain.sql +++ b/setup/sql/mailtrain.sql @@ -185,7 +185,7 @@ CREATE TABLE `settings` ( `value` text NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `key` (`key`) -) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=utf8mb4; INSERT INTO `settings` (`id`, `key`, `value`) VALUES (1,'smtp_hostname','localhost'); INSERT INTO `settings` (`id`, `key`, `value`) VALUES (2,'smtp_port','465'); INSERT INTO `settings` (`id`, `key`, `value`) VALUES (3,'smtp_encryption','TLS'); @@ -202,7 +202,7 @@ INSERT INTO `settings` (`id`, `key`, `value`) VALUES (13,'default_from','My Awes INSERT INTO `settings` (`id`, `key`, `value`) VALUES (14,'default_address','admin@example.com'); INSERT INTO `settings` (`id`, `key`, `value`) VALUES (15,'default_subject','Test message'); INSERT INTO `settings` (`id`, `key`, `value`) VALUES (16,'default_homepage','http://localhost:3000/'); -INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','13'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','14'); CREATE TABLE `subscription` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `cid` varchar(255) CHARACTER SET ascii NOT NULL, @@ -212,6 +212,7 @@ CREATE TABLE `subscription` ( `tz` varchar(100) CHARACTER SET ascii DEFAULT NULL, `imported` int(11) unsigned DEFAULT NULL, `status` tinyint(4) unsigned NOT NULL DEFAULT '1', + `is_test` tinyint(4) unsigned NOT NULL DEFAULT '0', `status_change` timestamp NULL DEFAULT NULL, `latest_open` timestamp NULL DEFAULT NULL, `latest_click` timestamp NULL DEFAULT NULL, @@ -224,7 +225,8 @@ CREATE TABLE `subscription` ( KEY `status` (`status`), KEY `first_name` (`first_name`(191)), KEY `last_name` (`last_name`(191)), - KEY `subscriber_tz` (`tz`) + KEY `subscriber_tz` (`tz`), + KEY `is_test` (`is_test`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `templates` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, diff --git a/setup/sql/upgrade-00014.sql b/setup/sql/upgrade-00014.sql new file mode 100644 index 00000000..f9f2ede0 --- /dev/null +++ b/setup/sql/upgrade-00014.sql @@ -0,0 +1,17 @@ +# Header section +# Define incrementing schema version number +SET @schema_version = '14'; + +-- {{#each tables.subscription}} + + # Adds new column 'tz' to subscriptions table + # Indicates subscriber time zone, use UTC as default + ALTER TABLE `{{this}}` ADD COLUMN `is_test` tinyint(4) unsigned NOT NULL DEFAULT '0' AFTER `status`; + CREATE INDEX is_test ON `{{this}}` (`is_test`); + +-- {{/each}} + +# Footer section +LOCK TABLES `settings` WRITE; +INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version; +UNLOCK TABLES; diff --git a/views/campaigns/view.hbs b/views/campaigns/view.hbs index c736592c..21ddb5cd 100644 --- a/views/campaigns/view.hbs +++ b/views/campaigns/view.hbs @@ -87,6 +87,31 @@ {{#if isNormal}} +
Preview campaign as
+
+
+ + + + +
+ +
+ +
+
+ {{#unless isIdling}}
Delivered
{{delivered}}
diff --git a/views/lists/subscription/add.hbs b/views/lists/subscription/add.hbs index 11e1df23..9d58af1a 100644 --- a/views/lists/subscription/add.hbs +++ b/views/lists/subscription/add.hbs @@ -131,6 +131,17 @@ +
+
+
+ +
+
+
+
diff --git a/views/lists/subscription/edit.hbs b/views/lists/subscription/edit.hbs index 7a1233a0..e45ef648 100644 --- a/views/lists/subscription/edit.hbs +++ b/views/lists/subscription/edit.hbs @@ -144,6 +144,17 @@
+
+
+
+ +
+
+
+