Added initial support for trigger based automation

This commit is contained in:
Andris Reinman 2016-06-03 13:15:33 +03:00
parent cc1c70d57f
commit b16209f23e
36 changed files with 2025 additions and 263 deletions

3
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules node_modules
npm-debug.log npm-debug.log
.DS_Store .DS_Store
development.toml config/development.*
config/production.*
dump.rdb dump.rdb

View file

@ -4,8 +4,6 @@
![](http://mailtrain.org/mailtrain.png) ![](http://mailtrain.org/mailtrain.png)
> **NB!** I'm running an IndieGoGo campaign to help fund developing first class automation support into Mailtrain. See all details here: [https://igg.me/at/mailtrain/8720095](https://igg.me/at/mailtrain/8720095)
## Features ## Features
Mailtrain supports subscriber list management, list segmentation, custom fields, email templates, large CSV list import files, etc. Mailtrain supports subscriber list management, list segmentation, custom fields, email templates, large CSV list import files, etc.

4
app.js
View file

@ -27,6 +27,7 @@ let campaigns = require('./routes/campaigns');
let links = require('./routes/links'); let links = require('./routes/links');
let fields = require('./routes/fields'); let fields = require('./routes/fields');
let segments = require('./routes/segments'); let segments = require('./routes/segments');
let triggers = require('./routes/triggers');
let webhooks = require('./routes/webhooks'); let webhooks = require('./routes/webhooks');
let subscription = require('./routes/subscription'); let subscription = require('./routes/subscription');
let archive = require('./routes/archive'); let archive = require('./routes/archive');
@ -150,7 +151,7 @@ app.use((req, res, next) => {
res.locals.menu = menu; res.locals.menu = menu;
tools.updateMenu(res); tools.updateMenu(res);
settingsModel.list(['ua_code'], (err, configItems) => { settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => {
if (err) { if (err) {
return next(err); return next(err);
} }
@ -170,6 +171,7 @@ app.use('/settings', settings);
app.use('/links', links); app.use('/links', links);
app.use('/fields', fields); app.use('/fields', fields);
app.use('/segments', segments); app.use('/segments', segments);
app.use('/triggers', triggers);
app.use('/webhooks', webhooks); app.use('/webhooks', webhooks);
app.use('/subscription', subscription); app.use('/subscription', subscription);
app.use('/archive', archive); app.use('/archive', archive);

View file

@ -25,13 +25,15 @@ log="dev"
proxy=true proxy=true
# maximum POST body size # maximum POST body size
postsize="2MB" postsize="2MB"
# Uncomment to set uploads folder location for temporary data. Defaults to os.tmpdir()
#tmpdir=/tmp
[mysql] [mysql]
host="localhost" host="localhost"
user="mailtrain" user="mailtrain"
password="mailtrain" password="mailtrain"
database="mailtrain" database="mailtrain"
port=3306 # some installations, eg. MAMP can use a different port (8889). MAMP users should turn on "Allow network access to MySQL" port=3306 # some installations, eg. MAMP can use a different port (8889). MAMP users should turn on "Allow network access to MySQL"
charset="utf8mb4" charset="utf8mb4"
timezone="local" timezone="local"

View file

@ -9,6 +9,7 @@ let log = require('npmlog');
let app = require('./app'); let app = require('./app');
let http = require('http'); let http = require('http');
let sender = require('./services/sender'); let sender = require('./services/sender');
let triggers = require('./services/triggers');
let importer = require('./services/importer'); let importer = require('./services/importer');
let verpServer = require('./services/verp-server'); let verpServer = require('./services/verp-server');
let testServer = require('./services/test-server'); let testServer = require('./services/test-server');
@ -75,25 +76,27 @@ server.on('listening', () => {
verpServer(() => { verpServer(() => {
tzupdate(() => { tzupdate(() => {
importer(() => { importer(() => {
sender(() => { triggers(() => {
feedcheck(() => { sender(() => {
log.info('Service', 'All services started'); feedcheck(() => {
if (config.group) { log.info('Service', 'All services started');
try { if (config.group) {
process.setgid(config.group); try {
log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid()); process.setgid(config.group);
} catch (E) { log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid());
log.info('Service', 'Failed to change group to "%s" (%s)', config.group, E.message); } catch (E) {
log.info('Service', 'Failed to change group to "%s" (%s)', config.group, E.message);
}
} }
} if (config.user) {
if (config.user) { try {
try { process.setuid(config.user);
process.setuid(config.user); log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid());
log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid()); } catch (E) {
} catch (E) { log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message);
log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message); }
} }
} });
}); });
}); });
}); });

View file

@ -21,7 +21,7 @@ module.exports.list = (start, limit, callback) => {
return callback(err); return callback(err);
} }
connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM campaigns ORDER BY name LIMIT ? OFFSET ?', [limit, start], (err, rows) => { connection.query('SELECT SQL_CALC_FOUND_ROWS * FROM campaigns ORDER BY scheduled DESC LIMIT ? OFFSET ?', [limit, start], (err, rows) => {
if (err) { if (err) {
connection.release(); connection.release();
return callback(err); return callback(err);
@ -122,8 +122,8 @@ module.exports.filter = (request, parent, callback) => {
processQuery({ processQuery({
// only find normal and RSS parent campaigns at this point // only find normal and RSS parent campaigns at this point
where: '`type` IN (?,?)', where: '`type` IN (?,?,?)',
values: [1, 2] values: [1, 2, 4]
}); });
} }
}; };
@ -428,6 +428,9 @@ module.exports.create = (campaign, opts, callback) => {
} }
switch ((campaign.type || '').toString().trim().toLowerCase()) { switch ((campaign.type || '').toString().trim().toLowerCase()) {
case 'triggered':
campaign.type = 4;
break;
case 'rss': case 'rss':
campaign.type = 2; campaign.type = 2;
break; break;
@ -453,13 +456,26 @@ module.exports.create = (campaign, opts, callback) => {
return callback(new Error('RSS URL must be set and needs to be a valid URL')); return callback(new Error('RSS URL must be set and needs to be a valid URL'));
} }
lists.get(campaign.list, (err, list) => { let getList = (listId, callback) => {
if (campaign.type === 4) {
return callback(null, false);
}
lists.get(listId, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(new Error('Selected list not found'));
}
return callback(null, list);
});
};
getList(campaign.list, err => {
if (err) { if (err) {
return callback(err); return callback(err);
} }
if (!list) {
return callback(new Error('Selected list not found'));
}
let keys = ['name', 'type']; let keys = ['name', 'type'];
let values = [name, campaign.type]; let values = [name, campaign.type];
@ -474,6 +490,11 @@ module.exports.create = (campaign, opts, callback) => {
values.push(2, opts.parent); values.push(2, opts.parent);
} }
if (campaign.type === 4) {
keys.push('status');
values.push(6); // active
}
let create = next => { let create = next => {
Object.keys(campaign).forEach(key => { Object.keys(campaign).forEach(key => {
let value = typeof campaign[key] === 'number' ? campaign[key] : (campaign[key] || '').toString().trim(); let value = typeof campaign[key] === 'number' ? campaign[key] : (campaign[key] || '').toString().trim();
@ -623,13 +644,22 @@ module.exports.update = (id, updates, callback) => {
campaign.segment = 0; campaign.segment = 0;
} }
lists.get(campaign.list, (err, list) => { let getList = (listId, callback) => {
lists.get(listId, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(new Error('Selected list not found'));
}
return callback(null, list);
});
};
getList(campaign.list, err => {
if (err) { if (err) {
return callback(err); return callback(err);
} }
if (!list) {
return callback(new Error('Selected list not found'));
}
let keys = ['name']; let keys = ['name'];
let values = [name]; let values = [name];
@ -728,27 +758,19 @@ module.exports.delete = (id, callback) => {
} }
connection.query('DELETE FROM campaigns WHERE id=? LIMIT 1', [id], (err, result) => { connection.query('DELETE FROM campaigns WHERE id=? LIMIT 1', [id], (err, result) => {
connection.release();
if (err) { if (err) {
connection.release();
return callback(err); return callback(err);
} }
let affected = result && result.affectedRows || 0; let affected = result && result.affectedRows || 0;
removeCampaignTables(id, err => {
connection.query('DELETE FROM links WHERE campaign=?', [id], err => {
connection.release();
if (err) { if (err) {
return callback(err); return callback(err);
} }
removeCampaignTables(id, err => { caches.cache.delete('sender queue');
if (err) { return callback(null, affected);
return callback(err);
}
caches.cache.delete('sender queue');
return callback(null, affected);
});
}); });
}); });
}); });
@ -841,7 +863,7 @@ module.exports.reset = (id, callback) => {
} }
caches.cache.delete('sender queue'); caches.cache.delete('sender queue');
connection.query('DELETE FROM links WHERE campaign=?', [id], err => { connection.query('UPDATE links SET `clicks`=0 WHERE campaign=?', [id], err => {
if (err) { if (err) {
connection.release(); connection.release();
return callback(err); return callback(err);

View file

@ -11,32 +11,25 @@ let lists = require('./lists');
let log = require('npmlog'); let log = require('npmlog');
let urllib = require('url'); let urllib = require('url');
let he = require('he');
module.exports.resolve = (campaignCid, linkCid, callback) => { module.exports.resolve = (linkCid, callback) => {
campaigns.getByCid(campaignCid, (err, campaign) => { db.getConnection((err, connection) => {
if (err) { if (err) {
return callback(err); return callback(err);
} }
if (!campaign) { let query = 'SELECT id, url FROM links WHERE `cid`=? LIMIT 1';
return callback('Campaign not found'); connection.query(query, [linkCid], (err, rows) => {
} connection.release();
db.getConnection((err, connection) => {
if (err) { if (err) {
return callback(err); return callback(err);
} }
let query = 'SELECT id, url FROM links WHERE `campaign`=? AND `cid`=? LIMIT 1';
connection.query(query, [campaign.id, linkCid], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (rows && rows.length) { if (rows && rows.length) {
return callback(null, rows[0].id, rows[0].url); return callback(null, rows[0].id, rows[0].url);
} }
return callback(null, false); return callback(null, false);
});
}); });
}); });
}; };
@ -46,6 +39,9 @@ module.exports.countClick = (remoteIp, campaignCid, listCid, subscriptionCid, li
if (err) { if (err) {
return callback(err); return callback(err);
} }
if(!data){
return callback(null, false);
}
db.getConnection((err, connection) => { db.getConnection((err, connection) => {
if (err) { if (err) {
return callback(err); return callback(err);
@ -295,7 +291,9 @@ module.exports.updateLinks = (campaign, list, subscription, serviceUrl, message,
return replaceUrls(); return replaceUrls();
} }
module.exports.add(urlItem.value, campaign.id, (err, linkId, cid) => { module.exports.add(he.decode(urlItem.value, {
isAttributeValue: true
}), campaign.id, (err, linkId, cid) => {
if (err) { if (err) {
log.error('Link', err.stack); log.error('Link', err.stack);
return storeNext(); return storeNext();

369
lib/models/triggers.js Normal file
View file

@ -0,0 +1,369 @@
'use strict';
let tools = require('../tools');
let db = require('../db');
let lists = require('./lists');
let util = require('util');
module.exports.defaultColumns = [{
column: 'created',
name: 'Sign up date',
type: 'date'
}, {
column: 'latest_open',
name: 'Latest open',
type: 'date'
}, {
column: 'latest_click',
name: 'Latest click',
type: 'date'
}];
module.exports.defaultCampaignEvents = [{
option: 'delivered',
name: 'Delivered'
}, {
option: 'opened',
name: 'Has Opened'
}, {
option: 'clicked',
name: 'Has Clicked'
}, {
option: 'not_opened',
name: 'Not Opened'
}, {
option: 'not_clicked',
name: 'Not Clicked'
}];
let defaultColumnMap = {};
let defaultEventMap = {};
module.exports.defaultColumns.forEach(col => defaultColumnMap[col.column] = col.name);
module.exports.defaultCampaignEvents.forEach(evt => defaultEventMap[evt.option] = evt.name);
module.exports.list = callback => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let tableFields = [
'`triggers`.`id` AS `id`',
'`triggers`.`name` AS `name`',
'`triggers`.`description` AS `description`',
'`triggers`.`enabled` AS `enabled`',
'`triggers`.`list` AS `list`',
'`lists`.`name` AS `list_name`',
'`source`.`id` AS `source_campaign`',
'`source`.`name` AS `source_campaign_name`',
'`dest`.`id` AS `dest_campaign`',
'`dest`.`name` AS `dest_campaign_name`',
'`custom_fields`.`id` AS `column_id`',
'`triggers`.`column` AS `column`',
'`custom_fields`.`name` AS `column_name`',
'`triggers`.`rule` AS `rule`',
'`triggers`.`seconds` AS `seconds`',
'`triggers`.`created` AS `created`'
];
let query = 'SELECT ' + tableFields.join(', ') + ' FROM `triggers` LEFT JOIN `campaigns` `source` ON `source`.`id`=`triggers`.`source_campaign` LEFT JOIN `campaigns` `dest` ON `dest`.`id`=`triggers`.`dest_campaign` LEFT JOIN `lists` ON `lists`.`id`=`triggers`.`list` LEFT JOIN `custom_fields` ON `custom_fields`.`list` = `triggers`.`list` AND `custom_fields`.`column`=`triggers`.`column` ORDER BY `triggers`.`name`';
connection.query(query, (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let triggers = (rows || []).map(tools.convertKeys).map(row => {
if (row.rule === 'subscription' && row.column && !row.columnName) {
row.columnName = defaultColumnMap[row.column];
}
let days = Math.round(row.seconds / (24 * 3600));
row.formatted = util.format('%s days after %s', days, row.rule === 'subscription' ? row.columnName : (util.format('%s <a href="/campaigns/view/%s">%s</a>', defaultEventMap[row.column], row.sourceCampaign, row.sourceCampaignName)));
return row;
});
return callback(null, triggers);
});
});
};
module.exports.getQuery = (id, callback) => {
module.exports.get(id, (err, trigger) => {
if (err) {
return callback(err);
}
let limit = 300;
let treshold = 3600 * 24; // time..NOW..time+24h
let intervalQuery = (column, seconds, treshold) => column + ' <= NOW() - INTERVAL ' + seconds + ' SECOND AND ' + column + ' >= NOW() - INTERVAL ' + (treshold + seconds) + ' SECOND';
let query = false;
switch (trigger.rule) {
case 'subscription':
query = 'SELECT id FROM `subscription__' + trigger.list + '` subscription WHERE ' + intervalQuery('`' + trigger.column + '`', trigger.seconds, treshold) + ' AND id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'campaign':
switch (trigger.column) {
case 'delivered':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription WHERE campaign.status=1 AND ' + intervalQuery('`campaign`.`created`', trigger.seconds, treshold) + ' AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'not_clicked':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription LEFT JOIN `campaign_tracker__' + trigger.sourceCampaign + '` tracker ON tracker.list=campaign.list AND tracker.subscriber=subscription.id AND tracker.link=0 WHERE campaign.status=1 AND ' + intervalQuery('`campaign`.`created`', trigger.seconds, treshold) + ' AND tracker.created IS NULL AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'not_opened':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription LEFT JOIN `campaign_tracker__' + trigger.sourceCampaign + '` tracker ON tracker.list=campaign.list AND tracker.subscriber=subscription.id AND tracker.link=-1 WHERE campaign.status=1 AND ' + intervalQuery('`campaign`.`created`', trigger.seconds, treshold) + ' AND tracker.created IS NULL AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'clicked':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription LEFT JOIN `campaign_tracker__' + trigger.sourceCampaign + '` tracker ON tracker.list=campaign.list AND tracker.subscriber=subscription.id AND tracker.link=0 WHERE campaign.status=1 AND ' + intervalQuery('`tracker`.`created`', trigger.seconds, treshold) + ' AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'opened':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription LEFT JOIN `campaign_tracker__' + trigger.sourceCampaign + '` tracker ON tracker.list=campaign.list AND tracker.subscriber=subscription.id AND tracker.link=-1 WHERE campaign.status=1 AND ' + intervalQuery('`tracker`.`created`', trigger.seconds, treshold) + ' AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
}
break;
}
callback(null, query);
});
};
module.exports.get = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error('Missing Trigger ID'));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM triggers WHERE id=?', [id], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
let trigger = tools.convertKeys(rows[0]);
return callback(null, trigger);
});
});
};
module.exports.create = (trigger, callback) => {
trigger = tools.convertKeys(trigger);
let name = (trigger.name || '').toString().trim();
let description = (trigger.description || '').toString().trim();
let listId = Number(trigger.list) || 0;
let seconds = (Number(trigger.days) || 0) * 24 * 3600;
let rule = (trigger.rule || '').toString().toLowerCase().trim();
let destCampaign = Number(trigger.destCampaign) || 0;
let sourceCampaign = null;
let column;
if (!listId) {
return callback(new Error('Missing or invalid list ID'));
}
if (seconds < 0) {
return callback(new Error('Days in the past are not allowed'));
}
if (!rule || ['campaign', 'subscription'].indexOf(rule) < 0) {
return callback(new Error('Missing or invalid trigger rule'));
}
switch (rule) {
case 'subscription':
column = (trigger.column || '').toString().toLowerCase().trim();
if (!column) {
return callback(new Error('Invalid subscription configuration'));
}
break;
case 'campaign':
column = (trigger.campaignOption || '').toString().toLowerCase().trim();
sourceCampaign = Number(trigger.sourceCampaign) || 0;
if (!column || !sourceCampaign) {
return callback(new Error('Invalid campaign configuration'));
}
if (sourceCampaign === destCampaign) {
return callback(new Error('A campaing can not be a target for itself'));
}
break;
default:
return callback(new Error('Missing or invalid trigger rule'));
}
lists.get(listId, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(new Error('Missing or invalid list ID'));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let keys = ['name', 'description', 'list', 'source_campaign', 'rule', 'column', 'seconds', 'dest_campaign', 'last_check'];
let values = [name, description, list.id, sourceCampaign, rule, column, seconds, destCampaign];
let query = 'INSERT INTO `triggers` (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(', ') + ', NOW())';
connection.query(query, values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let id = result && result.insertId;
if (!id) {
return callback(new Error('Could not store trigger row'));
}
createTriggerTable(id, err => {
if (err) {
return callback(err);
}
return callback(null, id);
});
});
});
});
};
module.exports.update = (id, trigger, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error('Missing or invalid Trigger ID'));
}
trigger = tools.convertKeys(trigger);
let name = (trigger.name || '').toString().trim();
let description = (trigger.description || '').toString().trim();
let enabled = trigger.enabled ? 1 : 0;
let seconds = (Number(trigger.days) || 0) * 24 * 3600;
let rule = (trigger.rule || '').toString().toLowerCase().trim();
let destCampaign = Number(trigger.destCampaign) || 0;
let sourceCampaign = null;
let column;
if (seconds < 0) {
return callback(new Error('Days in the past are not allowed'));
}
if (!rule || ['campaign', 'subscription'].indexOf(rule) < 0) {
return callback(new Error('Missing or invalid trigger rule'));
}
switch (rule) {
case 'subscription':
column = (trigger.column || '').toString().toLowerCase().trim();
if (!column) {
return callback(new Error('Invalid subscription configuration'));
}
break;
case 'campaign':
column = (trigger.campaignOption || '').toString().toLowerCase().trim();
sourceCampaign = Number(trigger.sourceCampaign) || 0;
if (!column || !sourceCampaign) {
return callback(new Error('Invalid campaign configuration'));
}
if (sourceCampaign === destCampaign) {
return callback(new Error('A campaing can not be a target for itself'));
}
break;
default:
return callback(new Error('Missing or invalid trigger rule'));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let keys = ['name', 'description', 'enabled', 'source_campaign', 'rule', 'column', 'seconds', 'dest_campaign'];
let values = [name, description, enabled, sourceCampaign, rule, column, seconds, destCampaign];
let query = 'UPDATE `triggers` SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE `id`=? LIMIT 1';
connection.query(query, values.concat(id), (err, result) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, result && result.affectedRows);
});
});
};
module.exports.delete = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error('Missing Trigger ID'));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM triggers WHERE id=? LIMIT 1', [id], (err, result) => {
if (err) {
connection.release();
return callback(err);
}
let affected = result && result.affectedRows || 0;
removeTriggerTable(id, err => {
if (err) {
return callback(err);
}
return callback(null, affected);
});
});
});
};
function createTriggerTable(id, callback) {
let query = 'CREATE TABLE `trigger__' + id + '` LIKE `trigger`';
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query(query, err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
}
function removeTriggerTable(id, callback) {
let query = 'DROP TABLE IF EXISTS `trigger__' + id + '`';
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query(query, err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
}

View file

@ -116,6 +116,10 @@ function updateMenu(res) {
title: 'Campaigns', title: 'Campaigns',
url: '/campaigns', url: '/campaigns',
key: 'campaigns' key: 'campaigns'
}, {
title: 'Automation',
url: '/triggers',
key: 'triggers'
}); });
} }
@ -155,7 +159,10 @@ function getMessageLinks(serviceUrl, campaign, list, subscription) {
return { return {
LINK_UNSUBSCRIBE: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?auto=yes&c=' + campaign.cid), LINK_UNSUBSCRIBE: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?auto=yes&c=' + campaign.cid),
LINK_PREFERENCES: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid), LINK_PREFERENCES: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid),
LINK_BROWSER: urllib.resolve(serviceUrl, '/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid) LINK_BROWSER: urllib.resolve(serviceUrl, '/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid),
CAMPAIGN_ID: campaign.cid,
LIST_ID: list.cid,
SUBSCRIPTION_ID: subscription.cid
}; };
} }

View file

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

View file

@ -33,34 +33,36 @@
"body-parser": "^1.15.1", "body-parser": "^1.15.1",
"bounce-handler": "^7.3.2-fork.0", "bounce-handler": "^7.3.2-fork.0",
"compression": "^1.6.2", "compression": "^1.6.2",
"config": "^1.20.4", "config": "^1.21.0",
"connect-flash": "^0.1.1", "connect-flash": "^0.1.1",
"connect-redis": "^3.0.2", "connect-redis": "^3.1.0",
"cookie-parser": "^1.4.3", "cookie-parser": "^1.4.3",
"csurf": "^1.9.0", "csurf": "^1.9.0",
"csv-parse": "^1.1.0", "csv-parse": "^1.1.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"express": "^4.13.4", "express": "^4.14.0",
"express-session": "^1.13.0", "express-session": "^1.13.0",
"faker": "^3.1.0", "faker": "^3.1.0",
"feedparser": "^1.1.4", "feedparser": "^1.1.4",
"geoip-ultralight": "^0.1.3", "geoip-ultralight": "^0.1.3",
"handlebars": "^4.0.5", "handlebars": "^4.0.5",
"hbs": "^4.0.0", "hbs": "^4.0.0",
"he": "^1.1.0",
"html-to-text": "^2.1.0", "html-to-text": "^2.1.0",
"humanize": "0.0.9", "humanize": "0.0.9",
"is-url": "^1.2.1", "is-url": "^1.2.1",
"isemail": "^2.1.2", "isemail": "^2.1.2",
"jsdom": "^9.2.1", "jsdom": "^9.2.1",
"juice": "^2.0.0", "juice": "^2.0.0",
"mkdirp": "^0.5.1",
"moment-timezone": "^0.5.4", "moment-timezone": "^0.5.4",
"morgan": "^1.7.0", "morgan": "^1.7.0",
"multer": "^1.1.0", "multer": "^1.1.0",
"mysql": "^2.10.2", "mysql": "^2.11.1",
"nodemailer": "^2.4.2", "nodemailer": "^2.4.2",
"nodemailer-openpgp": "^1.0.2", "nodemailer-openpgp": "^1.0.2",
"npmlog": "^2.0.4", "npmlog": "^3.1.2",
"openpgp": "^2.3.0", "openpgp": "^2.3.2",
"passport": "^0.3.2", "passport": "^0.3.2",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"request": "^2.72.0", "request": "^2.72.0",

BIN
public/mailtrain-header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -58,13 +58,13 @@ router.get('/:campaign/:list/:subscription', (req, res, next) => {
req.flash('danger', err.message || err); req.flash('danger', err.message || err);
return res.redirect('/'); return res.redirect('/');
} }
/*
if (!mail && !req.user) { if (!mail && !req.user) {
err = new Error('Not Found'); err = new Error('Not Found');
err.status = 404; err.status = 404;
return next(err); return next(err);
} }
*/
let renderAndShow = (html, renderTags) => { let renderAndShow = (html, renderTags) => {
// rewrite links to count clicks // rewrite links to count clicks

View file

@ -90,6 +90,9 @@ router.get('/create', passport.csrfProtection, (req, res) => {
case 'rss': case 'rss':
view = 'campaigns/create-rss'; view = 'campaigns/create-rss';
break; break;
case 'triggered':
view = 'campaigns/create-triggered';
break;
default: default:
view = 'campaigns/create'; view = 'campaigns/create';
} }
@ -151,6 +154,9 @@ router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
let view; let view;
switch (campaign.type) { switch (campaign.type) {
case 4: //triggered
view = 'campaigns/edit-triggered';
break;
case 2: //rss case 2: //rss
view = 'campaigns/edit-rss'; view = 'campaigns/edit-rss';
break; break;
@ -159,55 +165,72 @@ router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
view = 'campaigns/edit'; view = 'campaigns/edit';
} }
lists.get(campaign.list, (err, list) => { let getList = (listId, callback) => {
lists.get(listId, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(new Error('Selected list not found'));
}
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];
}
let mergeTags = [
// keep indentation
{
key: 'LINK_UNSUBSCRIBE',
value: 'URL that points to the preferences page of the subscriber'
}, {
key: 'LINK_PREFERENCES',
value: 'URL that points to the unsubscribe page'
}, {
key: 'LINK_BROWSER',
value: 'URL to preview the message in a browser'
}, {
key: 'FIRST_NAME',
value: 'First name'
}, {
key: 'LAST_NAME',
value: 'Last name'
}, {
key: 'FULL_NAME',
value: 'Full name (first and last name combined)'
}, {
key: 'SUBSCRIPTION_ID',
value: 'Unique ID that identifies the recipient'
}, {
key: 'LIST_ID',
value: 'Unique ID that identifies the list used for this campaign'
}, {
key: 'CAMPAIGN_ID',
value: 'Unique ID that identifies current campaign'
}
];
fieldList.forEach(field => {
mergeTags.push({
key: field.key,
value: field.name
});
});
return callback(null, list, mergeTags);
});
});
};
getList(campaign.list, (err, list, mergeTags) => {
if (err) { if (err) {
req.flash('danger', err.message || err); req.flash('danger', err.message || err);
return res.redirect('/'); return res.redirect('/');
} }
if (!list) {
req.flash('danger', 'Selected list does not exist');
res.render(view, campaign);
return;
}
fields.list(list.id, (err, fieldList) => { campaign.mergeTags = mergeTags;
if (err && !fieldList) { res.render(view, campaign);
fieldList = [];
}
let mergeTags = [
// indent
{
key: 'LINK_UNSUBSCRIBE',
value: 'URL that points to the preferences page of the subscriber'
}, {
key: 'LINK_PREFERENCES',
value: 'URL that points to the unsubscribe page'
}, {
key: 'LINK_BROWSER',
value: 'URL to preview the message in a browser'
}, {
key: 'FIRST_NAME',
value: 'First name'
}, {
key: 'LAST_NAME',
value: 'Last name'
}, {
key: 'FULL_NAME',
value: 'Full name (first and last name combined)'
}
];
fieldList.forEach(field => {
mergeTags.push({
key: field.key,
value: field.name
});
});
campaign.mergeTags = mergeTags;
res.render(view, campaign);
});
}); });
}); });
}); });
@ -298,61 +321,74 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => {
return res.redirect('/campaigns'); return res.redirect('/campaigns');
} }
lists.get(campaign.list, (err, list) => { let getList = (listId, callback) => {
if (err || !campaign) { lists.get(listId, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(new Error('Selected list not found'));
}
subscriptions.listTestUsers(listId, (err, testUsers) => {
if (err || !testUsers) {
testUsers = [];
}
return callback(null, list, testUsers);
});
});
};
getList(campaign.list, (err, list, testUsers) => {
if (err) {
req.flash('danger', err && err.message || err); req.flash('danger', err && err.message || err);
return res.redirect('/campaigns'); return res.redirect('/campaigns');
} }
campaign.csrfToken = req.csrfToken(); campaign.csrfToken = req.csrfToken();
campaign.list = list; campaign.list = list;
campaign.testUsers = testUsers;
subscriptions.listTestUsers(list.id, (err, testUsers) => { campaign.isIdling = campaign.status === 1;
if (err || !testUsers) { campaign.isSending = campaign.status === 2;
testUsers = []; 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.isTriggered = campaign.type === 4;
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.testUsers = testUsers; campaign.links = (links || []).map(link => {
campaign.isIdling = campaign.status === 1; link.index = ++index;
campaign.isSending = campaign.status === 2; link.totalPercentage = campaign.delivered ? Math.round(((link.clicks / campaign.delivered) * 100) * 1000) / 1000 : 0;
campaign.isFinished = campaign.status === 3; link.relPercentage = campaign.clicks ? Math.round(((link.clicks / campaign.clicks) * 100) * 1000) / 1000 : 0;
campaign.isPaused = campaign.status === 4; link.short = link.url.replace(/^https?:\/\/(www.)?/i, '');
campaign.isInactive = campaign.status === 5; if (link.short > 63) {
campaign.isActive = campaign.status === 6; link.short = link.short.substr(0, 60) + '…';
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; return link;
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.showOverview = !req.query.tab || req.query.tab === 'overview';
campaign.showLinks = req.query.tab === 'links';
res.render('campaigns/view', campaign);
}); });
}); });
}); });
@ -423,8 +459,20 @@ router.get('/status/:id/:status', passport.csrfProtection, (req, res) => {
return res.redirect('/campaigns'); return res.redirect('/campaigns');
} }
lists.get(campaign.list, (err, list) => { let getList = (listId, callback) => {
if (err || !campaign) { lists.get(listId, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(new Error('Selected list not found'));
}
return callback(null, list);
});
};
getList(campaign.list, (err, list) => {
if (err) {
req.flash('danger', err && err.message || err); req.flash('danger', err && err.message || err);
return res.redirect('/campaigns'); return res.redirect('/campaigns');
} }
@ -449,8 +497,20 @@ router.get('/clicked/:id/:linkId', passport.csrfProtection, (req, res) => {
return res.redirect('/campaigns'); return res.redirect('/campaigns');
} }
lists.get(campaign.list, (err, list) => { let getList = (listId, callback) => {
if (err || !campaign) { lists.get(listId, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(new Error('Selected list not found'));
}
return callback(null, list);
});
};
getList(campaign.list, (err, list) => {
if (err) {
req.flash('danger', err && err.message || err); req.flash('danger', err && err.message || err);
return res.redirect('/campaigns'); return res.redirect('/campaigns');
} }

View file

@ -30,12 +30,28 @@ router.get('/:campaign/:list/:subscription', (req, res) => {
res.end(trackImg); res.end(trackImg);
}); });
router.get('/:campaign/:list/:subscription/:link', (req, res, next) => { router.get('/:campaign/:list/:subscription/:link', (req, res) => {
links.resolve(req.params.campaign, req.params.link, (err, linkId, url) => {
let notFound = () => {
res.status(404);
return res.render('archive/view', {
layout: 'archive/layout',
message: 'Oops, we couldn\'t find a link for the URL you clicked',
campaign: {
subject: 'Error 404'
}
});
};
links.resolve(req.params.link, (err, linkId, url) => {
if (err) { if (err) {
req.flash('danger', err.message || err); req.flash('danger', err.message || err);
return res.redirect('/'); return res.redirect('/');
} }
if (!linkId || !url) {
log.error('Redirect', 'Unresolved URL: <%s>', req.url);
return notFound();
}
links.countClick(req.ip, req.params.campaign, req.params.list, req.params.subscription, linkId, (err, status) => { links.countClick(req.ip, req.params.campaign, req.params.list, req.params.subscription, linkId, (err, status) => {
if (err) { if (err) {
log.error('Redirect', err.stack || err); log.error('Redirect', err.stack || err);
@ -58,9 +74,8 @@ router.get('/:campaign/:list/:subscription/:link', (req, res, next) => {
} }
if (!list) { if (!list) {
err = new Error('Not Found'); log.error('Redirect', 'Could not resolve list for merge tags: <%s>', req.url);
err.status = 404; return notFound();
return next(err);
} }
settings.get('serviceUrl', (err, serviceUrl) => { settings.get('serviceUrl', (err, serviceUrl) => {
@ -76,9 +91,8 @@ router.get('/:campaign/:list/:subscription/:link', (req, res, next) => {
} }
if (!subscription) { if (!subscription) {
err = new Error('Not Found'); log.error('Redirect', 'Could not resolve subscription for merge tags: <%s>', req.url);
err.status = 404; return notFound();
return next(err);
} }
url = tools.formatMessage(serviceUrl, { url = tools.formatMessage(serviceUrl, {

View file

@ -1,5 +1,6 @@
'use strict'; 'use strict';
let config = require('config');
let openpgp = require('openpgp'); let openpgp = require('openpgp');
let passport = require('../lib/passport'); let passport = require('../lib/passport');
let express = require('express'); let express = require('express');
@ -13,9 +14,30 @@ let htmlescape = require('escape-html');
let multer = require('multer'); let multer = require('multer');
let os = require('os'); let os = require('os');
let humanize = require('humanize'); let humanize = require('humanize');
let uploads = multer({ let mkdirp = require('mkdirp');
dest: os.tmpdir() let pathlib = require('path');
let log = require('npmlog');
let uploadStorage = multer.diskStorage({
destination: (req, file, callback) => {
log.verbose('tmpdir', os.tmpdir());
let tmp = config.www.tmpdir || os.tmpdir();
let dir = pathlib.join(tmp, 'mailtrain');
mkdirp(dir, err => {
if (err) {
log.error('Upload', err);
log.verbose('Upload', 'Storing upload to <%s>', tmp);
return callback(null, tmp);
}
log.verbose('Upload', 'Storing upload to <%s>', dir);
callback(null, dir);
});
}
}); });
let uploads = multer({
storage: uploadStorage
});
let csvparse = require('csv-parse'); let csvparse = require('csv-parse');
let fs = require('fs'); let fs = require('fs');
let moment = require('moment-timezone'); let moment = require('moment-timezone');

View file

@ -11,7 +11,7 @@ let url = require('url');
let settings = require('../lib/models/settings'); let settings = require('../lib/models/settings');
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', 'pgp_private_key', 'pgp_passphrase', 'ua_code']; 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', 'pgp_private_key', 'pgp_passphrase', 'ua_code', 'shoutout'];
router.all('/*', (req, res, next) => { router.all('/*', (req, res, next) => {
if (!req.user) { if (!req.user) {
@ -57,58 +57,51 @@ router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) =
let data = tools.convertKeys(req.body); let data = tools.convertKeys(req.body);
tools.validateEmail(data.adminEmail, false, err => { let keys = [];
if (err) { let values = [];
req.flash('danger', err && err.message || err);
Object.keys(data).forEach(key => {
let value = data[key].trim();
key = tools.toDbKey(key);
// ensure trailing slash for service home page
if (key === 'service_url' && value && !/\/$/.test(value)) {
value = value + '/';
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);
}
});
// 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 = () => {
if (i >= keys.length) {
mailer.update();
req.flash('success', 'Settings updated');
return res.redirect('/settings'); return res.redirect('/settings');
} }
let key = keys[i];
let value = values[i];
i++;
let keys = []; settings.set(key, value, err => {
let values = []; if (err) {
req.flash('danger', err && err.message || err);
Object.keys(data).forEach(key => {
let value = data[key].trim();
key = tools.toDbKey(key);
// ensure trailing slash for service home page
if (key === 'service_url' && value && !/\/$/.test(value)) {
value = value + '/';
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);
}
});
// 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 = () => {
if (i >= keys.length) {
mailer.update();
req.flash('success', 'Settings updated');
return res.redirect('/settings'); return res.redirect('/settings');
} }
let key = keys[i]; storeSettings();
let value = values[i]; });
i++; };
settings.set(key, value, err => { storeSettings();
if (err) {
req.flash('danger', err && err.message || err);
return res.redirect('/settings');
}
storeSettings();
});
};
storeSettings();
});
}); });
router.post('/smtp-verify', passport.parseForm, passport.csrfProtection, (req, res) => { router.post('/smtp-verify', passport.parseForm, passport.csrfProtection, (req, res) => {

235
routes/triggers.js Normal file
View file

@ -0,0 +1,235 @@
'use strict';
let express = require('express');
let router = new express.Router();
let triggers = require('../lib/models/triggers');
let campaigns = require('../lib/models/campaigns');
let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields');
let striptags = require('striptags');
let passport = require('../lib/passport');
let tools = require('../lib/tools');
router.all('/*', (req, res, next) => {
if (!req.user) {
req.flash('danger', 'Need to be logged in to access restricted content');
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
}
res.setSelectedMenu('triggers');
next();
});
router.get('/', (req, res) => {
triggers.list((err, rows) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
res.render('triggers/triggers', {
rows: rows.map((row, i) => {
row.index = i + 1;
row.description = striptags(row.description);
return row;
})
});
});
});
router.get('/create-select', passport.csrfProtection, (req, res, next) => {
let data = tools.convertKeys(req.query, {
skip: ['layout']
});
data.csrfToken = req.csrfToken();
lists.quicklist((err, listItems) => {
if (err) {
return next(err);
}
data.listItems = listItems;
res.render('triggers/create-select', data);
});
});
router.post('/create-select', passport.parseForm, passport.csrfProtection, (req, res) => {
if (!req.body.list) {
req.flash('danger', 'Could not find selected list');
return res.redirect('/triggers/create-select');
}
res.redirect('/triggers/' + encodeURIComponent(req.body.list) + '/create');
});
router.get('/:listId/create', passport.csrfProtection, (req, res, next) => {
let data = tools.convertKeys(req.query, {
skip: ['layout']
});
data.csrfToken = req.csrfToken();
data.days = Math.max(Number(data.days) || 1, 1);
lists.get(req.params.listId, (err, list) => {
if (err || !list) {
req.flash('danger', err && err.message || err || 'Could not find selected list');
return res.redirect('/triggers/create-select');
}
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];
}
data.columns = triggers.defaultColumns.concat(fieldList.filter(field => fields.genericTypes[field.type] === 'date')).map(field => ({
column: field.column,
name: field.name,
selected: data.column === field.column
}));
campaigns.list(0, 300, (err, campaignList) => {
if (err) {
return next(err);
}
data.sourceCampaigns = (campaignList || []).filter(campaign => campaign.list === list.id).map(campaign => ({
id: campaign.id,
name: campaign.name,
selected: Number(data.sourceCampaign) === campaign.id
}));
data.destCampaigns = (campaignList || []).filter(campaign => campaign.list === list.id && campaign.type === 4).map(campaign => ({
id: campaign.id,
name: campaign.name,
selected: Number(data.destCampaign) === campaign.id
}));
data.list = list;
data.isSubscription = data.rule === 'subscription' || !data.rule;
data.isCampaign = data.rule === 'campaign';
data.campaignOptions = triggers.defaultCampaignEvents.map(evt => ({
option: evt.option,
name: evt.name,
selected: Number(data.sourceCampaign) === evt.option
}));
data.isSend = true;
res.render('triggers/create', data);
});
});
});
});
router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => {
triggers.create(req.body, (err, id) => {
if (err || !id) {
req.flash('danger', err && err.message || err || 'Could not create trigger');
if (req.body.list) {
return res.redirect('/triggers/' + encodeURIComponent(req.body.list) + '/create?' + tools.queryParams(req.body));
} else {
return res.redirect('/triggers');
}
}
req.flash('success', 'Trigger “' + req.body.name + '” created');
res.redirect('/triggers');
});
});
router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
triggers.get(req.params.id, (err, trigger) => {
if (err || !trigger) {
req.flash('danger', err && err.message || err || 'Could not find campaign with specified ID');
return res.redirect('/campaigns');
}
trigger.csrfToken = req.csrfToken();
trigger.days = Math.round(trigger.seconds / (24 * 3600));
lists.get(trigger.list, (err, list) => {
if (err || !list) {
req.flash('danger', err && err.message || err || 'Could not find selected list');
return res.redirect('/triggers');
}
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];
}
campaigns.list(0, 300, (err, campaignList) => {
if (err) {
return next(err);
}
trigger.sourceCampaigns = (campaignList || []).filter(campaign => campaign.list === list.id).map(campaign => ({
id: campaign.id,
name: campaign.name,
selected: Number(trigger.sourceCampaign) === campaign.id
}));
trigger.destCampaigns = (campaignList || []).filter(campaign => campaign.list === list.id && campaign.type === 4).map(campaign => ({
id: campaign.id,
name: campaign.name,
selected: Number(trigger.destCampaign) === campaign.id
}));
trigger.list = list;
trigger.isSubscription = trigger.rule === 'subscription' || !trigger.rule;
trigger.isCampaign = trigger.rule === 'campaign';
trigger.columns = triggers.defaultColumns.concat(fieldList.filter(field => fields.genericTypes[field.type] === 'date')).map(field => ({
column: field.column,
name: field.name,
selected: trigger.isSubscription && trigger.column === field.column
}));
trigger.campaignOptions = triggers.defaultCampaignEvents.map(evt => ({
option: evt.option,
name: evt.name,
selected: trigger.isCampaign && trigger.column === evt.option
}));
if (trigger.rule !== 'subscription') {
trigger.column = null;
}
trigger.isSend = true;
res.render('triggers/edit', trigger);
});
});
});
});
});
router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
triggers.update(req.body.id, req.body, (err, updated) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/triggers/edit/' + encodeURIComponent(req.body.id));
} else if (updated) {
req.flash('success', 'Trigger settings updated');
} else {
req.flash('info', 'Trigger settings not updated');
}
return res.redirect('/triggers');
});
});
router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => {
triggers.delete(req.body.id, (err, deleted) => {
if (err) {
req.flash('danger', err && err.message || err);
} else if (deleted) {
req.flash('success', 'Trigger deleted');
} else {
req.flash('info', 'Could not delete specified trigger');
}
return res.redirect('/triggers');
});
});
module.exports = router;

View file

@ -46,6 +46,61 @@ function findUnsent(callback) {
}); });
}; };
// get next subscriber from trigger queue
let checkQueued = () => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM `queued` ORDER BY `created` ASC LIMIT 1', (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
if (!rows || !rows.length) {
connection.release();
return callback(null, false);
}
let queued = tools.convertKeys(rows[0]);
// delete queued element
connection.query('DELETE FROM `queued` WHERE `campaign`=? AND `list`=? AND `subscriber`=? LIMIT 1', [queued.campaign, queued.list, queued.subscriber], err => {
if (err) {
connection.release();
return callback(err);
}
// get campaign
connection.query('SELECT `id`, `list`, `segment` FROM `campaigns` WHERE `id`=? LIMIT 1', [queued.campaign], (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
if (!rows || !rows.length) {
connection.release();
return callback(null, false);
}
let campaign = tools.convertKeys(rows[0]);
// get subscription
connection.query('SELECT * FROM `subscription__' + queued.list + '` WHERE `id`=? AND `status`=1 LIMIT 1', [queued.subscriber], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
return returnUnsent(rows[0], campaign);
});
});
});
});
});
};
if (caches.cache.has('sender queue')) { if (caches.cache.has('sender queue')) {
let cached = caches.shift('sender queue'); let cached = caches.shift('sender queue');
return returnUnsent(cached.row, cached.campaign); return returnUnsent(cached.row, cached.campaign);
@ -64,7 +119,7 @@ function findUnsent(callback) {
return callback(err); return callback(err);
} }
if (!rows || !rows.length) { if (!rows || !rows.length) {
return callback(null, false); return checkQueued();
} }
let campaign = tools.convertKeys(rows[0]); let campaign = tools.convertKeys(rows[0]);

112
services/triggers.js Normal file
View file

@ -0,0 +1,112 @@
'use strict';
let log = require('npmlog');
let db = require('../lib/db');
let tools = require('../lib/tools');
let triggers = require('../lib/models/triggers');
function triggerLoop() {
checkTrigger((err, triggerId) => {
if (err) {
log.error('Triggers', err);
}
if (triggerId) {
return setImmediate(triggerLoop);
} else {
return setTimeout(triggerLoop, 15 * 1000);
}
});
}
function checkTrigger(callback) {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM `triggers` WHERE `enabled`=1 AND `last_check`<=NOW()-INTERVAL 1 MINUTE ORDER BY `last_check` ASC LIMIT 1';
connection.query(query, (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
if (!rows || !rows.length) {
connection.release();
return callback(null, false);
}
let trigger = tools.convertKeys(rows[0]);
let query = 'UPDATE `triggers` SET `last_check`=NOW() WHERE id=? LIMIT 1';
connection.query(query, [trigger.id], err => {
connection.release();
if (err) {
return callback(err);
}
triggers.getQuery(trigger.id, (err, query) => {
if (err) {
return callback(err);
}
if (!query) {
return callback(new Error('Unknown trigger type ' + trigger.id));
}
trigger.query = query;
fireTrigger(trigger, callback);
});
});
});
});
}
function fireTrigger(trigger, callback) {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query(trigger.query, (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, trigger.id);
}
let pos = 0;
let insertNext = () => {
if (pos >= rows.length) {
return callback(null, trigger.id);
}
let subscriber = rows[pos++].id;
let query = 'INSERT INTO `trigger__' + trigger.id + '` (`list`, `subscription`) VALUES (?,?)';
let values = [trigger.list, subscriber];
connection.query(query, values, (err, result) => {
if (err && err.code !== 'ER_DUP_ENTRY') {
connection.release();
return callback(err);
}
if (!result.affectedRows) {
return setImmediate(insertNext);
}
log.verbose('Triggers', 'Triggered %s (%s) for subscriber %s', trigger.name, trigger.id, subscriber);
let query = 'INSERT INTO `queued` (`campaign`, `list`, `subscriber`, `source`) VALUES (?,?,?,?)';
let values = [trigger.destCampaign, trigger.list, subscriber, 'trigger ' + trigger.id];
connection.query(query, values, err => {
if (err && err.code !== 'ER_DUP_ENTRY') {
connection.release();
return callback(err);
}
return setImmediate(insertNext);
});
});
};
insertNext();
});
});
}
module.exports = callback => {
triggerLoop();
setImmediate(callback);
};

View file

@ -13,5 +13,5 @@ respawn limit 10 0
script script
cd /opt/mailtrain cd /opt/mailtrain
exec npm start >> /var/log/mailtrain.log 2>&1 exec node index.js >> /var/log/mailtrain.log 2>&1
end script end script

View file

@ -185,7 +185,7 @@ CREATE TABLE `settings` (
`value` text NOT NULL, `value` text NOT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `key` (`key`) UNIQUE KEY `key` (`key`)
) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB AUTO_INCREMENT=32 DEFAULT CHARSET=utf8mb4;
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (1,'smtp_hostname','localhost'); 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 (2,'smtp_port','465');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (3,'smtp_encryption','TLS'); 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 (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 (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 (16,'default_homepage','http://localhost:3000/');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','14'); INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','15');
CREATE TABLE `subscription` ( CREATE TABLE `subscription` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT, `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` varchar(255) CHARACTER SET ascii NOT NULL, `cid` varchar(255) CHARACTER SET ascii NOT NULL,
@ -238,6 +238,31 @@ CREATE TABLE `templates` (
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `name` (`name`(191)) KEY `name` (`name`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `trigger` (
`list` int(11) unsigned NOT NULL,
`subscription` int(11) unsigned NOT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`list`,`subscription`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `triggers` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL DEFAULT '',
`description` text,
`list` int(11) unsigned NOT NULL,
`source_campaign` int(11) unsigned DEFAULT NULL,
`rule` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT 'column',
`column` varchar(255) CHARACTER SET ascii DEFAULT NULL,
`seconds` int(11) NOT NULL DEFAULT '0',
`dest_campaign` int(11) unsigned DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `name` (`name`(191)),
KEY `source_campaign` (`source_campaign`),
KEY `dest_campaign` (`dest_campaign`),
KEY `list` (`list`),
KEY `column` (`column`),
CONSTRAINT `triggers_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `tzoffset` ( CREATE TABLE `tzoffset` (
`tz` varchar(100) NOT NULL DEFAULT '', `tz` varchar(100) NOT NULL DEFAULT '',
`offset` int(11) NOT NULL DEFAULT '0', `offset` int(11) NOT NULL DEFAULT '0',

View file

@ -0,0 +1,59 @@
# Header section
# Define incrementing schema version number
SET @schema_version = '15';
# table for trigger definitions
CREATE TABLE `triggers` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL DEFAULT '',
`description` text,
`enabled` tinyint(4) unsigned NOT NULL DEFAULT '1',
`list` int(11) unsigned NOT NULL,
`source_campaign` int(11) unsigned DEFAULT NULL,
`rule` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT 'column',
`column` varchar(255) CHARACTER SET ascii DEFAULT NULL,
`seconds` int(11) NOT NULL DEFAULT '0',
`dest_campaign` int(11) unsigned DEFAULT NULL,
`last_check` timestamp NULL DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `name` (`name`(191)),
KEY `source_campaign` (`source_campaign`),
KEY `dest_campaign` (`dest_campaign`),
KEY `list` (`list`),
KEY `column` (`column`),
KEY `active` (`enabled`),
KEY `last_check` (`last_check`),
CONSTRAINT `triggers_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
# base table for triggered matches
CREATE TABLE `trigger` (
`list` int(11) unsigned NOT NULL,
`subscription` int(11) unsigned NOT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`list`,`subscription`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# table for yet queued messages ready to be sent
CREATE TABLE `queued` (
`campaign` int(11) unsigned NOT NULL,
`list` int(11) unsigned NOT NULL,
`subscriber` int(11) unsigned NOT NULL,
`source` varchar(255) DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`campaign`,`list`,`subscriber`),
KEY `created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- {{#each tables.subscription}}
# Adds indexes for triggers
CREATE INDEX latest_open ON `{{this}}` (`latest_open`);
CREATE INDEX latest_click ON `{{this}}` (`latest_click`);
CREATE INDEX created ON `{{this}}` (`created`);
-- {{/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

@ -9,8 +9,9 @@
Create Campaign <span class="caret"></span> Create Campaign <span class="caret"></span>
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="/campaigns/create"><i class="glyphicon glyphicon-plus"></i> Normal Campaign</a></li> <li><a href="/campaigns/create"><i class="glyphicon glyphicon-plus"></i> Normal Campaign</a></li>
<li><a href="/campaigns/create?type=rss"><i class="glyphicon glyphicon-signal"></i> RSS Campaign</a></li> <li><a href="/campaigns/create?type=rss"><i class="glyphicon glyphicon-signal"></i> RSS Campaign</a></li>
<li><a href="/campaigns/create?type=triggered"><i class="glyphicon glyphicon-console"></i> Triggered Campaign</a></li>
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -0,0 +1,110 @@
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
<li><a href="/campaigns">Campaigns</a></li>
<li class="active">Create Triggered Campaign</li>
</ol>
<h2>Create Triggered Campaign</h2>
<hr>
<form class="form-horizontal" method="post" action="/campaigns/create">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="type" value="triggered">
<div class="form-group">
<label for="name" class="col-sm-2 control-label">Name</label>
<div class="col-sm-10">
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="Campaign Name" autofocus required>
</div>
</div>
<div class="form-group">
<label for="description" class="col-sm-2 control-label">Description</label>
<div class="col-sm-10">
<textarea class="form-control" rows="3" name="description" id="description">{{description}}</textarea>
<span class="help-block">HTML is allowed</span>
</div>
</div>
<div class="form-group">
<label for="list" class="col-sm-2 control-label">List</label>
<div class="col-sm-10">
<select class="form-control" id="list" name="list" required>
<option value=""> Select </option>
{{#each listItems}}
<option value="{{id}}" {{#if selected}} selected {{/if}}>
{{name}} <span class="text-muted"> &mdash; {{subscribers}} subscribers</span>
</option>
{{#if segments}}
<optgroup label="{{name}} segments">
{{#each segments}}
<option value="{{../id}}:{{id}}" {{#if selected}} selected {{/if}}>
{{../name}}: {{name}}
</option>
{{/each}}
</optgroup>
{{/if}}
{{/each}}
</select>
</div>
</div>
<div class="form-group">
<label for="template" class="col-sm-2 control-label">Template</label>
<div class="col-sm-10">
<p class="form-control-static">
Select a template:
</p>
<div>
<select class="form-control" id="template" name="template">
<option value=""> Select </option>
{{#each templateItems}}
<option value="{{id}}" {{#if selected}} selected {{/if}}>
{{name}}
</option>
{{/each}}
</select>
<span class="help-block">Selecting a template creates a campaign specific copy from it</span>
</div>
<p class="form-control-static">
Or alternatively use an URL as the message content source:
</p>
<div>
<input type="url" class="form-control" name="source-url" id="source-url" value="{{sourceUrl}}" placeholder="http://example.com/message-render.php">
<span class="help-block">If a message is sent then this URL will be POSTed to using Merge Tags as POST body. Use this if you want to generate the HTML message yourself</span>
</div>
</div>
</div>
<hr />
<div class="form-group">
<label for="from" class="col-sm-2 control-label">Email "from name"</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="from" id="from" value="{{from}}" placeholder="This is the name your emails will come from" required>
</div>
</div>
<div class="form-group">
<label for="address" class="col-sm-2 control-label">Email "from" address</label>
<div class="col-sm-10">
<input type="email" class="form-control" name="address" id="address" value="{{address}}" placeholder="This is the address people will send replies to" required>
</div>
</div>
<div class="form-group">
<label for="subject" class="col-sm-2 control-label">Email "subject line"</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="subject" id="subject" value="{{subject}}" placeholder="Keep it relevant and non-spammy" required>
</div>
</div>
<hr />
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> Create Campaign</button>
</div>
</div>
</form>

View file

@ -0,0 +1,196 @@
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
<li><a href="/campaigns">Campaigns</a></li>
{{#if parent}}
<li><a href="/campaigns/view/{{parent.id}}">{{parent.name}}</a></li>
{{/if}}
<li><a href="/campaigns/view/{{id}}">{{name}}</a></li>
<li class="active">Edit Triggered Campaign</li>
</ol>
<h2>Edit Triggered Campaign <a class="btn btn-default btn-xs" href="/campaigns/view/{{id}}" role="button"><span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span> View campaign</a></h2>
<hr>
<form method="post" class="delete-form" id="campaigns-delete" action="/campaigns/delete">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="id" value="{{id}}" />
</form>
<form class="form-horizontal" method="post" action="/campaigns/edit">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="id" value="{{id}}" />
<input type="hidden" name="type" value="triggered" />
<div>
<!-- Nav tabs -->
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="{{#if showGeneral}}active{{/if}}"><a href="#general" aria-controls="general" role="tab" data-toggle="tab">General</a></li>
<li role="presentation" class="{{#if showTemplate}}active{{/if}}"><a href="#template" aria-controls="template" role="tab" data-toggle="tab">Template</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane {{#if showGeneral}}active{{/if}}" id="general">
<p></p>
<fieldset>
<legend>
General Settings
</legend>
<div class="form-group">
<label for="name" class="col-sm-2 control-label">Name</label>
<div class="col-sm-10">
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="Campaign Name" autofocus required>
</div>
</div>
<div class="form-group">
<label for="description" class="col-sm-2 control-label">Description</label>
<div class="col-sm-10">
<textarea class="form-control" rows="3" name="description" id="description">{{description}}</textarea>
<span class="help-block">HTML is allowed</span>
</div>
</div>
<div class="form-group">
<label for="list" class="col-sm-2 control-label">List</label>
<div class="col-sm-10">
<select class="form-control" id="list" name="list" required>
<option value=""> Select </option>
{{#each listItems}}
<option value="{{id}}" {{#if selected}} selected {{/if}}>
{{name}} <span class="text-muted"> &mdash; {{subscribers}} subscribers</span>
</option>
{{#if segments}}
<optgroup label="{{name}} segments">
{{#each segments}}
<option value="{{../id}}:{{id}}" {{#if selected}} selected {{/if}}>
{{../name}}: {{name}}
</option>
{{/each}}
</optgroup>
{{/if}}
{{/each}}
</select>
</div>
</div>
<hr />
<div class="form-group">
<label for="from" class="col-sm-2 control-label">Email "from name"</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="from" id="from" value="{{from}}" placeholder="This is the name your emails will come from" required>
</div>
</div>
<div class="form-group">
<label for="address" class="col-sm-2 control-label">Email "from" address</label>
<div class="col-sm-10">
<input type="email" class="form-control" name="address" id="address" value="{{address}}" placeholder="This is the address people will send replies to" required>
</div>
</div>
<div class="form-group">
<label for="subject" class="col-sm-2 control-label">Email "subject line"</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="subject" id="subject" value="{{subject}}" placeholder="Keep it relevant and non-spammy" required>
</div>
</div>
</fieldset>
</div>
<div role="tabpanel" class="tab-pane {{#if showTemplate}}active{{/if}}" id="template">
<p></p>
<fieldset>
<legend>
Template Settings
</legend>
{{#if sourceUrl}}
<div class="form-group">
<label for="source-url" class="col-sm-2 control-label">Template URL</label>
<div class="col-sm-10">
<input type="url" class="form-control" name="source-url" id="source-url" value="{{sourceUrl}}" placeholder="http://example.com/message-render.php">
<span class="help-block">If a message is sent then this URL will be POSTed to using Merge Tags as POST body. Use this if you want to generate the HTML message yourself</span>
</div>
</div>
{{else}}
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<a class="btn btn-default" role="button" data-toggle="collapse" href="#mergeReference" aria-expanded="false" aria-controls="mergeReference">Merge tag reference</a>
<div class="collapse" id="mergeReference">
<p>
Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>[TAG_NAME]</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional
text value used when <code>TAG_NAME</code> is empty.
</p>
<table class="table table-bordered table-condensed table-striped">
<thead>
<tr>
<th>
Merge tag
</th>
<th>
Description
</th>
</tr>
</thead>
<tbody>
{{#each mergeTags}}
<tr>
<th scope="row">
[{{key}}]
</th>
<td>
{{value}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</div>
<div class="form-group">
<label for="template-html" class="col-sm-2 control-label">Template content (HTML)</label>
<div class="col-sm-10">
{{#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" id="template-text" name="text" rows="10">{{text}}</textarea>
</div>
</div>
{{/if}}
</fieldset>
</div>
</div>
</div>
<hr />
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="pull-right">
<button type="submit" form="campaigns-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> Delete Campaign</button>
</div>
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> Update</button>
</div>
</div>
</form>

View file

@ -33,27 +33,25 @@
<p></p> <p></p>
<dl class="dl-horizontal"> <dl class="dl-horizontal">
<dt>List</dt> {{#if list}}
<dd> <dt>List</dt>
{{#if segment}} <dd>
<a href="/lists/view/{{list.id}}?segment={{segment.id}}"> {{#if segment}}
{{list.name}}: {{segment.name}} <a href="/lists/view/{{list.id}}?segment={{segment.id}}">{{list.name}}: {{segment.name}}</a>
</a> {{else}}
{{else}} <a href="/lists/view/{{list.id}}">{{list.name}}</a>
<a href="/lists/view/{{list.id}}"> {{/if}}
{{list.name}} </dd>
</a>
{{/if}}
</dd>
<dt>List subscribers</dt> <dt>List subscribers</dt>
<dd> <dd>
{{#if segment}} {{#if segment}}
{{segment.subscribers}} {{segment.subscribers}}
{{else}} {{else}}
{{list.subscribers}} {{list.subscribers}}
{{/if}} {{/if}}
</dd> </dd>
{{/if}}
{{#if isRss}} {{#if isRss}}
<dt>Feed URL</dt> <dt>Feed URL</dt>
@ -85,7 +83,7 @@
<dd>{{subject}}</dd> <dd>{{subject}}</dd>
{{/if}} {{/if}}
{{#if isNormal}} {{#unless isRss}}
<dt>Preview campaign as</dt> <dt>Preview campaign as</dt>
<dd> <dd>
@ -166,7 +164,7 @@
</dd> </dd>
{{/unless}} {{/unless}}
{{/if}} {{/unless}}
</dl> </dl>
{{#if isNormal}} {{#if isNormal}}
@ -313,6 +311,14 @@
</div> </div>
{{/if}} {{/if}}
{{#if isTriggered}}
<div class="panel panel-default">
<div class="panel-body">
This is a <a href="/triggers">triggered</a> campaign. Messages are only sent to subscribers that hit some trigger that invokes this campaign
</div>
</div>
{{/if}}
</div> </div>
{{#if links}} {{#if links}}
<div role="tabpanel" class="tab-pane {{#if showLinks}}active{{/if}}" id="links"> <div role="tabpanel" class="tab-pane {{#if showLinks}}active{{/if}}" id="links">

View file

@ -1,11 +1,11 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="alert alert-danger fade in" role=alert> <div class="alert alert-danger fade in" role=alert>
<h4>Oh snap! I'm running a crowdfunding campaign!</h4> <h4>Donate to author</h4>
<p>I'm running an IndieGoGo campaign to help fund developing first class automation support into Mailtrain and You can make it possible! Alternatively you can donate to the Mailtrain project directly with Paypal. If you prefer Bitcoin, then my <p>If you really like Mailtrain then you can donate to the Mailtrain project to support the further development of it. You can donate directly with Paypal or if you prefer Bitcoin, then my wallet address is <code>15Z8ADxhssKUiwP3jbbqJwA21744KMCfTM</code>.</p>
wallet address is <code>15Z8ADxhssKUiwP3jbbqJwA21744KMCfTM</code></p> <p>If you are a business and can't donate because of tax reasons then I can sell you the Mailtrain license (GPL) with an invoice, just send me an email (<a href="andris@mailtrain.org">andris@mailtrain.org</a>) with the sum you are willing to pay for the license and I'll return an invoice
for it.</p>
<p> <p>
<a href="https://igg.me/at/mailtrain/8720095" class="btn btn-info">Support the Campaign</a>
<a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&amp;hosted_button_id=DB26KWR2BQX5W" class="btn btn-info">Donate using PayPal</a> <a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&amp;hosted_button_id=DB26KWR2BQX5W" class="btn btn-info">Donate using PayPal</a>
</p> </p>
</div> </div>

View file

@ -108,11 +108,11 @@
{{#if indexPage}} {{#if indexPage}}
<div class="jumbotron"> <div class="jumbotron">
<div class="container"> <div class="container">
<div class="pull-right hidden-xs"> <div class="pull-right col-md-4">
<img class="img-responsive" src="/mailtrain.png"> {{{shoutout}}}
</div> </div>
<h1>Mailtrain</h1> <h1><img class="img-responsive" src="/mailtrain-header.png"></h1>
<p>Self hosted newsletter app built on top of <a href="http://nodemailer.com">Nodemailer</a></p> <p>Self hosted newsletter app built on top of <a href="http://nodemailer.com">Nodemailer</a></p>
<p> <p>
<a class="btn btn-info btn-md" href="https://github.com/andris9/mailtrain" role="button"><span class="glyphicon glyphicon-cloud-download" aria-hidden="true"></span> Source on GitHub</a> <a class="btn btn-info btn-md" href="https://github.com/andris9/mailtrain" role="button"><span class="glyphicon glyphicon-cloud-download" aria-hidden="true"></span> Source on GitHub</a>
@ -121,9 +121,6 @@
</p> </p>
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="visible-xs-block ">
<img class="img-responsive" src="/mailtrain.png">
</div>
</div> </div>
</div> </div>
{{/if}} {{/if}}

View file

@ -16,6 +16,7 @@
<li><a href="/fields/{{id}}" role="button"><span class="glyphicon glyphicon-tasks" aria-hidden="true"></span> Custom Fields</a></li> <li><a href="/fields/{{id}}" role="button"><span class="glyphicon glyphicon-tasks" aria-hidden="true"></span> Custom Fields</a></li>
<li><a href="/segments/{{id}}" role="button"><span class="glyphicon glyphicon-filter" aria-hidden="true"></span> Segments</a></li> <li><a href="/segments/{{id}}" role="button"><span class="glyphicon glyphicon-filter" aria-hidden="true"></span> Segments</a></li>
<li><a href="/lists/edit/{{id}}" role="button"><span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> Edit List</a></li> <li><a href="/lists/edit/{{id}}" role="button"><span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> Edit List</a></li>
<li><a href="/triggers/{{id}}/create" role="button"><span class="glyphicon glyphicon-console" aria-hidden="true"></span> Create Trigger</a></li>
<li role="separator" class="divider"></li> <li role="separator" class="divider"></li>
<li><a href="/lists/subscription/{{id}}/add" role="button"><span class="glyphicon glyphicon-plus-sign" aria-hidden="true"></span> Add Subscriber</a></li> <li><a href="/lists/subscription/{{id}}/add" role="button"><span class="glyphicon glyphicon-plus-sign" aria-hidden="true"></span> Add Subscriber</a></li>
<li><a href="/lists/subscription/{{id}}/import" role="button"><span class="glyphicon glyphicon-cloud-upload" aria-hidden="true"></span> Import Subscribers</a></li> <li><a href="/lists/subscription/{{id}}/import" role="button"><span class="glyphicon glyphicon-cloud-upload" aria-hidden="true"></span> Import Subscribers</a></li>

View file

@ -55,6 +55,14 @@
</div> </div>
</div> </div>
<div class="form-group">
<label for="shoutout" class="col-sm-2 control-label">Frontpage shout out</label>
<div class="col-sm-10">
<textarea class="form-control gpg-text" rows="3" id="shoutout" name="shoutout" placeholder="">{{shoutout}}</textarea>
<span class="help-block">HTML code shown in the front page header section</span>
</div>
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>

View file

@ -46,6 +46,15 @@
<li> <li>
<code>[LINK_BROWSER]</code> URL to preview the message in a browser <code>[LINK_BROWSER]</code> URL to preview the message in a browser
</li> </li>
<li>
<code>[SUBSCRIPTION_ID]</code> Unique ID that identifies the recipient
</li>
<li>
<code>[LIST_ID]</code> Unique ID that identifies the list used for this campaign
</li>
<li>
<code>[CAMPAIGN_ID]</code> Unique ID that identifies current campaign
</li>
</ul> </ul>
<p> <p>
In addition to that any custom field can have its own merge tag. In addition to that any custom field can have its own merge tag.

View file

@ -0,0 +1,33 @@
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
<li><a href="/triggers/">Automation Triggers</a></li>
<li class="active">Create Trigger</li>
</ol>
<h2>Create Trigger <small>Select a list for the trigger</small></h2>
<hr>
<form class="form-horizontal" method="post" action="/triggers/create-select">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<div class="form-group">
<label for="group" class="col-sm-2 control-label">List</label>
<div class="col-sm-10">
<select class="form-control" name="list" required>
<option value=""> Select </option>
{{#each listItems}}
<option value="{{id}}">
{{name}} <span class="text-muted"> &mdash; {{subscribers}} subscribers</span>
</option>
{{/each}}
</select>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-triangle-right"></i> Next</button>
</div>
</div>
</form>

162
views/triggers/create.hbs Normal file
View file

@ -0,0 +1,162 @@
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
<li><a href="/triggers/">Automation Triggers</a></li>
<li class="active">Create Trigger</li>
</ol>
<h2>Create Trigger</h2>
<hr>
<form class="form-horizontal" method="post" action="/triggers/create">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="list" value="{{list.id}}">
<div class="form-group">
<label for="trigger-name" class="col-sm-2 control-label">Trigger name</label>
<div class="col-sm-10">
<input type="text" class="form-control input-lg" name="name" id="trigger-name" value="{{name}}" placeholder="Name for this trigger, eg. &quot;Inactive subscribers&quot;" required autofocus>
</div>
</div>
<div class="form-group">
<label for="trigger-description" class="col-sm-2 control-label">Description</label>
<div class="col-sm-10">
<textarea class="form-control" id="trigger-description" name="description" rows="3" placeholder="Optional comments about this trigger">{{description}}</textarea>
<span class="help-block">HTML is allowed</span>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">List</label>
<div class="col-sm-10">
<p class="form-control-static"><a href="/lists/view/{{list.id}}">{{list.name}}</a> <span class="text-muted"> {{list.subscribers}} subscribers</span></p>
</div>
</div>
<fieldset>
<legend>
Trigger rule
</legend>
<div class="form-group">
<label for="trigger-days" class="col-sm-2 control-label">Trigger fires</label>
<div class="col-sm-1">
<input type="number" class="form-control" name="days" id="trigger-days" value="{{days}}" placeholder="1" required>
</div>
<div class="col-sm-1">
<p class="form-control-static">days after:</p>
</div>
</div>
<div class="col-sm-offset-2 panel panel-default">
<div class="panel-heading">
<div class="radio">
<label>
<input type="radio" name="rule" value="subscription" {{#if isSubscription}} checked {{/if}}> Subscription
</label>
</div>
</div>
<div class="panel-body">
<div class="form-group">
<label for="column" class="col-sm-2 control-label">Event</label>
<div class="col-sm-10">
<select name="column" class="form-control">
<option value="">
Select
</option>
{{#each columns}}
<option value="{{column}}" {{#if selected}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
</div>
</div>
</div>
</div>
<div class="col-sm-offset-2 panel panel-default">
<div class="panel-heading">
<div class="radio">
<label>
<input type="radio" name="rule" value="campaign" {{#if isCampaign}} checked {{/if}}> Campaign
</label>
</div>
</div>
<div class="panel-body">
<div class="form-group">
<label for="source-campaign" class="col-sm-2 control-label">Campaign</label>
<div class="col-sm-10">
<select name="source-campaign" class="form-control">
<option value="">
Select
</option>
{{#each sourceCampaigns}}
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
</div>
</div>
<div class="form-group">
<label for="campaign-option" class="col-sm-2 control-label">Event</label>
<div class="col-sm-10">
<select name="campaign-option" id="campaign-option" class="form-control">
<option value="">
Select
</option>
{{#each campaignOptions}}
<option value="{{option}}" {{#if selected}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>
Trigger action
</legend>
<div class="col-sm-offset-2 panel panel-default">
<div class="panel-heading">
<div class="radio">
<label>
<input type="radio" name="action" value="send" {{#if isSend}} checked {{/if}}> Send campaign
</label>
</div>
</div>
<div class="panel-body">
<div class="form-group">
<label for="dest-campaign" class="col-sm-2 control-label">Campaign</label>
<div class="col-sm-10">
<select name="dest-campaign" class="form-control" required>
<option value="">
Select
</option>
{{#each destCampaigns}}
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
</div>
</div>
</div>
</div>
</fieldset>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> Create Trigger</button>
</div>
</div>
</form>

177
views/triggers/edit.hbs Normal file
View file

@ -0,0 +1,177 @@
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
<li><a href="/triggers/">Automation Triggers</a></li>
<li class="active">Edit Trigger</li>
</ol>
<h2>Edit Trigger <a class="btn btn-default btn-xs" href="/triggers/" role="button"><span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span> Back to triggers</a></h2>
<hr>
<form method="post" class="delete-form" id="triggers-delete" action="/triggers/delete">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="id" value="{{id}}" />
</form>
<form class="form-horizontal" method="post" action="/triggers/edit">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="id" value="{{id}}" />
<div class="form-group">
<label for="trigger-name" class="col-sm-2 control-label">Trigger name</label>
<div class="col-sm-10">
<input type="text" class="form-control input-lg" name="name" id="trigger-name" value="{{name}}" placeholder="Name for this trigger, eg. &quot;Inactive subscribers&quot;" required autofocus>
</div>
</div>
<div class="form-group">
<label for="trigger-description" class="col-sm-2 control-label">Description</label>
<div class="col-sm-10">
<textarea class="form-control" id="trigger-description" name="description" rows="3" placeholder="Optional comments about this trigger">{{description}}</textarea>
<span class="help-block">HTML is allowed</span>
</div>
</div>
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="enabled" value="1" {{#if enabled}} checked {{/if}}> Trigger is enabled
</label>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">List</label>
<div class="col-sm-10">
<p class="form-control-static"><a href="/lists/view/{{list.id}}">{{list.name}}</a> <span class="text-muted"> {{list.subscribers}} subscribers</span></p>
</div>
</div>
<fieldset>
<legend>
Trigger rule
</legend>
<div class="form-group">
<label for="trigger-days" class="col-sm-2 control-label">Trigger fires</label>
<div class="col-sm-1">
<input type="number" class="form-control" name="days" id="trigger-days" value="{{days}}" placeholder="1" required>
</div>
<div class="col-sm-1">
<p class="form-control-static">days after:</p>
</div>
</div>
<div class="col-sm-offset-2 panel panel-default">
<div class="panel-heading">
<div class="radio">
<label>
<input type="radio" name="rule" value="subscription" {{#if isSubscription}} checked {{/if}}> Subscription
</label>
</div>
</div>
<div class="panel-body">
<div class="form-group">
<label for="column" class="col-sm-2 control-label">Event</label>
<div class="col-sm-10">
<select name="column" class="form-control">
<option value="">
Select
</option>
{{#each columns}}
<option value="{{column}}" {{#if selected}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
</div>
</div>
</div>
</div>
<div class="col-sm-offset-2 panel panel-default">
<div class="panel-heading">
<div class="radio">
<label>
<input type="radio" name="rule" value="campaign" {{#if isCampaign}} checked {{/if}}> Campaign
</label>
</div>
</div>
<div class="panel-body">
<div class="form-group">
<label for="source-campaign" class="col-sm-2 control-label">Campaign</label>
<div class="col-sm-10">
<select name="source-campaign" class="form-control">
<option value="">
Select
</option>
{{#each sourceCampaigns}}
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
</div>
</div>
<div class="form-group">
<label for="campaign-option" class="col-sm-2 control-label">Event</label>
<div class="col-sm-10">
<select name="campaign-option" id="campaign-option" class="form-control">
<option value="">
Select
</option>
{{#each campaignOptions}}
<option value="{{option}}" {{#if selected}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>
Trigger action
</legend>
<div class="col-sm-offset-2 panel panel-default">
<div class="panel-heading">
<div class="radio">
<label>
<input type="radio" name="action" value="send" {{#if isSend}} checked {{/if}}> Send campaign
</label>
</div>
</div>
<div class="panel-body">
<div class="form-group">
<label for="dest-campaign" class="col-sm-2 control-label">Campaign</label>
<div class="col-sm-10">
<select name="dest-campaign" class="form-control" required>
<option value="">
Select
</option>
{{#each destCampaigns}}
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
</div>
</div>
</div>
</div>
</fieldset>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="pull-right">
<button type="submit" form="triggers-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> Delete Trigger</button>
</div>
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> Update</button>
</div>
</div>
</form>

View file

@ -0,0 +1,83 @@
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
<li class="active">Automation Triggers</li>
</ol>
<div class="pull-right">
<a class="btn btn-primary" href="/triggers/create-select" role="button"><i class="glyphicon glyphicon-plus"></i> Create Trigger</a>
</div>
<h2>Automation Triggers</h2>
<hr>
<div class="table-responsive">
<table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="0,1,1,0,1,0,1,0">
<thead>
<th class="col-md-1">
#
</th>
<th>
Name
</th>
<th>
Status
</th>
<th>
Description
</th>
<th>
List
</th>
<th>
Trigger
</th>
<th>
Target Campaign
</th>
<th class="col-md-1">
&nbsp;
</th>
</thead>
{{#if rows}}
<tbody>
{{#each rows}}
<tr>
<th scope="row">
{{index}}
</th>
<td>
<span class="glyphicon glyphicon-console" aria-hidden="true"></span> {{name}}
</td>
<td>
{{#if enabled}}
<span class="label label-success">Enabled</span>
{{else}}
<span class="label label-default">Disabled</span>
{{/if}}
</td>
<td class="text-muted">
{{description}}
</td>
<td class="text-info">
<a href="/lists/view/{{list}}">{{listName}}</a>
</td>
<td class="text-info">
{{{formatted}}}
</td>
<td class="text-info">
<a href="/campaigns/view/{{destCampaign}}">{{destCampaignName}}</a>
</td>
<td>
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>
<a href="/triggers/edit/{{id}}">
Edit
</a>
</td>
</tr>
{{/each}}
</tbody>
{{/if}}
</table>
</div>