This commit is contained in:
Andris Reinman 2016-05-31 17:32:36 +03:00
parent 3fa0e109af
commit 9bd6db2624
13 changed files with 201 additions and 51 deletions

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
{
"schemaVersion": 13
"schemaVersion": 14
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -87,6 +87,31 @@
{{#if isNormal}}
<dt>Preview campaign as</dt>
<dd>
<form method="post" action="/campaigns/preview/{{id}}" class="form-inline">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="campaign" value="{{cid}}">
<input type="hidden" name="list" value="{{list.cid}}">
<input type="hidden" name="listId" value="{{list.id}}">
<div class="form-group">
<select name="subscriber" class="form-control" required>
{{#each testUsers}}
<option value="{{cid}}">{{displayName}}</option>
{{/each}}
{{#if testUsers}}
<optgroup label="Actions">
<option value="_create">Add new test user ...</option>
</optgroup>
{{else}}
<option value="_create">No test users yet, create one here ...</option>
{{/if}}
</select>
</div>
<button type="submit" class="btn btn-default">Go</button>
</form>
</dd>
{{#unless isIdling}}
<dt>Delivered <a href="/campaigns/status/{{id}}/delivered" title="List subscribers who received this message"><span class="glyphicon glyphicon-zoom-in" aria-hidden="true"></span></a></dt>
<dd>{{delivered}}</dd>

View file

@ -131,6 +131,17 @@
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="checkbox">
<label>
<input type="checkbox" name="is-test" {{#if isTest}} checked {{/if}}> Test user?
<span class="help-block">If checked then this subscription can be used for previewing campaign messages</span>
</label>
</div>
</div>
</div>
<hr />
<div class="form-group">

View file

@ -144,6 +144,17 @@
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="checkbox">
<label>
<input type="checkbox" name="is-test" {{#if isTest}} checked {{/if}}> Test user?
<span class="help-block">If checked then this subscription can be used for previewing campaign messages</span>
</label>
</div>
</div>
</div>
<hr />
<div class="form-group">