From b16209f23e530f3408610ffebe2c420bf5bdea74 Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Fri, 3 Jun 2016 13:15:33 +0300 Subject: [PATCH] Added initial support for trigger based automation --- .gitignore | 3 +- README.md | 2 - app.js | 4 +- config/default.toml | 4 +- index.js | 37 +-- lib/models/campaigns.js | 70 +++-- lib/models/links.js | 34 ++- lib/models/triggers.js | 369 +++++++++++++++++++++++++++ lib/tools.js | 9 +- meta.json | 2 +- package.json | 16 +- public/mailtrain-header.png | Bin 0 -> 5772 bytes routes/archive.js | 4 +- routes/campaigns.js | 246 +++++++++++------- routes/links.js | 30 ++- routes/lists.js | 26 +- routes/settings.js | 87 +++---- routes/triggers.js | 235 +++++++++++++++++ services/sender.js | 57 ++++- services/triggers.js | 112 ++++++++ setup/mailtrain.conf | 2 +- setup/sql/mailtrain.sql | 29 ++- setup/sql/upgrade-00015.sql | 59 +++++ views/campaigns/campaigns.hbs | 5 +- views/campaigns/create-triggered.hbs | 110 ++++++++ views/campaigns/edit-triggered.hbs | 196 ++++++++++++++ views/campaigns/view.hbs | 50 ++-- views/index.hbs | 8 +- views/layout.hbs | 9 +- views/lists/view.hbs | 1 + views/settings.hbs | 8 + views/templates/create.hbs | 9 + views/triggers/create-select.hbs | 33 +++ views/triggers/create.hbs | 162 ++++++++++++ views/triggers/edit.hbs | 177 +++++++++++++ views/triggers/triggers.hbs | 83 ++++++ 36 files changed, 2025 insertions(+), 263 deletions(-) create mode 100644 lib/models/triggers.js create mode 100644 public/mailtrain-header.png create mode 100644 routes/triggers.js create mode 100644 services/triggers.js create mode 100644 setup/sql/upgrade-00015.sql create mode 100644 views/campaigns/create-triggered.hbs create mode 100644 views/campaigns/edit-triggered.hbs create mode 100644 views/triggers/create-select.hbs create mode 100644 views/triggers/create.hbs create mode 100644 views/triggers/edit.hbs create mode 100644 views/triggers/triggers.hbs diff --git a/.gitignore b/.gitignore index 8b8d217b..c9f7cd07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules npm-debug.log .DS_Store -development.toml +config/development.* +config/production.* dump.rdb diff --git a/README.md b/README.md index 2fec6cfd..88e31ecd 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ ![](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 Mailtrain supports subscriber list management, list segmentation, custom fields, email templates, large CSV list import files, etc. diff --git a/app.js b/app.js index 22eba2fb..062a2532 100644 --- a/app.js +++ b/app.js @@ -27,6 +27,7 @@ let campaigns = require('./routes/campaigns'); let links = require('./routes/links'); let fields = require('./routes/fields'); let segments = require('./routes/segments'); +let triggers = require('./routes/triggers'); let webhooks = require('./routes/webhooks'); let subscription = require('./routes/subscription'); let archive = require('./routes/archive'); @@ -150,7 +151,7 @@ app.use((req, res, next) => { res.locals.menu = menu; tools.updateMenu(res); - settingsModel.list(['ua_code'], (err, configItems) => { + settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => { if (err) { return next(err); } @@ -170,6 +171,7 @@ app.use('/settings', settings); app.use('/links', links); app.use('/fields', fields); app.use('/segments', segments); +app.use('/triggers', triggers); app.use('/webhooks', webhooks); app.use('/subscription', subscription); app.use('/archive', archive); diff --git a/config/default.toml b/config/default.toml index dfee7128..e3a1187b 100644 --- a/config/default.toml +++ b/config/default.toml @@ -25,13 +25,15 @@ log="dev" proxy=true # maximum POST body size postsize="2MB" +# Uncomment to set uploads folder location for temporary data. Defaults to os.tmpdir() +#tmpdir=/tmp [mysql] host="localhost" user="mailtrain" password="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" timezone="local" diff --git a/index.js b/index.js index 4b0fde8a..38432fef 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,7 @@ let log = require('npmlog'); let app = require('./app'); let http = require('http'); let sender = require('./services/sender'); +let triggers = require('./services/triggers'); let importer = require('./services/importer'); let verpServer = require('./services/verp-server'); let testServer = require('./services/test-server'); @@ -75,25 +76,27 @@ server.on('listening', () => { verpServer(() => { tzupdate(() => { importer(() => { - sender(() => { - feedcheck(() => { - log.info('Service', 'All services started'); - if (config.group) { - try { - process.setgid(config.group); - log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid()); - } catch (E) { - log.info('Service', 'Failed to change group to "%s" (%s)', config.group, E.message); + triggers(() => { + sender(() => { + feedcheck(() => { + log.info('Service', 'All services started'); + if (config.group) { + try { + process.setgid(config.group); + log.info('Service', 'Changed group to "%s" (%s)', config.group, process.getgid()); + } catch (E) { + log.info('Service', 'Failed to change group to "%s" (%s)', config.group, E.message); + } } - } - if (config.user) { - try { - process.setuid(config.user); - log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid()); - } catch (E) { - log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message); + if (config.user) { + try { + process.setuid(config.user); + log.info('Service', 'Changed user to "%s" (%s)', config.user, process.getuid()); + } catch (E) { + log.info('Service', 'Failed to change user to "%s" (%s)', config.user, E.message); + } } - } + }); }); }); }); diff --git a/lib/models/campaigns.js b/lib/models/campaigns.js index 7fc92066..3cc7b8c2 100644 --- a/lib/models/campaigns.js +++ b/lib/models/campaigns.js @@ -21,7 +21,7 @@ module.exports.list = (start, limit, callback) => { 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) { connection.release(); return callback(err); @@ -122,8 +122,8 @@ module.exports.filter = (request, parent, callback) => { processQuery({ // only find normal and RSS parent campaigns at this point - where: '`type` IN (?,?)', - values: [1, 2] + where: '`type` IN (?,?,?)', + values: [1, 2, 4] }); } }; @@ -428,6 +428,9 @@ module.exports.create = (campaign, opts, callback) => { } switch ((campaign.type || '').toString().trim().toLowerCase()) { + case 'triggered': + campaign.type = 4; + break; case 'rss': campaign.type = 2; 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')); } - 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) { return callback(err); } - if (!list) { - return callback(new Error('Selected list not found')); - } let keys = ['name', 'type']; let values = [name, campaign.type]; @@ -474,6 +490,11 @@ module.exports.create = (campaign, opts, callback) => { values.push(2, opts.parent); } + if (campaign.type === 4) { + keys.push('status'); + values.push(6); // active + } + let create = next => { Object.keys(campaign).forEach(key => { 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; } - 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) { return callback(err); } - if (!list) { - return callback(new Error('Selected list not found')); - } let keys = ['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.release(); if (err) { - connection.release(); return callback(err); } let affected = result && result.affectedRows || 0; - - connection.query('DELETE FROM links WHERE campaign=?', [id], err => { - connection.release(); + removeCampaignTables(id, err => { if (err) { return callback(err); } - removeCampaignTables(id, err => { - if (err) { - return callback(err); - } - - caches.cache.delete('sender queue'); - return callback(null, affected); - }); + caches.cache.delete('sender queue'); + return callback(null, affected); }); }); }); @@ -841,7 +863,7 @@ module.exports.reset = (id, callback) => { } 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) { connection.release(); return callback(err); diff --git a/lib/models/links.js b/lib/models/links.js index 7beeecd3..64b2790c 100644 --- a/lib/models/links.js +++ b/lib/models/links.js @@ -11,32 +11,25 @@ let lists = require('./lists'); let log = require('npmlog'); let urllib = require('url'); +let he = require('he'); -module.exports.resolve = (campaignCid, linkCid, callback) => { - campaigns.getByCid(campaignCid, (err, campaign) => { +module.exports.resolve = (linkCid, callback) => { + db.getConnection((err, connection) => { if (err) { return callback(err); } - if (!campaign) { - return callback('Campaign not found'); - } - db.getConnection((err, connection) => { + let query = 'SELECT id, url FROM links WHERE `cid`=? LIMIT 1'; + connection.query(query, [linkCid], (err, rows) => { + connection.release(); if (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) { - return callback(null, rows[0].id, rows[0].url); - } + if (rows && rows.length) { + 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) { return callback(err); } + if(!data){ + return callback(null, false); + } db.getConnection((err, connection) => { if (err) { return callback(err); @@ -295,7 +291,9 @@ module.exports.updateLinks = (campaign, list, subscription, serviceUrl, message, 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) { log.error('Link', err.stack); return storeNext(); diff --git a/lib/models/triggers.js b/lib/models/triggers.js new file mode 100644 index 00000000..63ade1e5 --- /dev/null +++ b/lib/models/triggers.js @@ -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 %s', 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); + }); + }); +} diff --git a/lib/tools.js b/lib/tools.js index baa6609c..bc43ad8c 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -116,6 +116,10 @@ function updateMenu(res) { title: 'Campaigns', url: '/campaigns', key: 'campaigns' + }, { + title: 'Automation', + url: '/triggers', + key: 'triggers' }); } @@ -155,7 +159,10 @@ function getMessageLinks(serviceUrl, campaign, list, subscription) { return { 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_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 }; } diff --git a/meta.json b/meta.json index bf813870..574f4279 100644 --- a/meta.json +++ b/meta.json @@ -1,3 +1,3 @@ { - "schemaVersion": 14 + "schemaVersion": 15 } diff --git a/package.json b/package.json index 9c7a20d9..59e61d5e 100644 --- a/package.json +++ b/package.json @@ -33,34 +33,36 @@ "body-parser": "^1.15.1", "bounce-handler": "^7.3.2-fork.0", "compression": "^1.6.2", - "config": "^1.20.4", + "config": "^1.21.0", "connect-flash": "^0.1.1", - "connect-redis": "^3.0.2", + "connect-redis": "^3.1.0", "cookie-parser": "^1.4.3", "csurf": "^1.9.0", - "csv-parse": "^1.1.0", + "csv-parse": "^1.1.1", "escape-html": "^1.0.3", - "express": "^4.13.4", + "express": "^4.14.0", "express-session": "^1.13.0", "faker": "^3.1.0", "feedparser": "^1.1.4", "geoip-ultralight": "^0.1.3", "handlebars": "^4.0.5", "hbs": "^4.0.0", + "he": "^1.1.0", "html-to-text": "^2.1.0", "humanize": "0.0.9", "is-url": "^1.2.1", "isemail": "^2.1.2", "jsdom": "^9.2.1", "juice": "^2.0.0", + "mkdirp": "^0.5.1", "moment-timezone": "^0.5.4", "morgan": "^1.7.0", "multer": "^1.1.0", - "mysql": "^2.10.2", + "mysql": "^2.11.1", "nodemailer": "^2.4.2", "nodemailer-openpgp": "^1.0.2", - "npmlog": "^2.0.4", - "openpgp": "^2.3.0", + "npmlog": "^3.1.2", + "openpgp": "^2.3.2", "passport": "^0.3.2", "passport-local": "^1.0.0", "request": "^2.72.0", diff --git a/public/mailtrain-header.png b/public/mailtrain-header.png new file mode 100644 index 0000000000000000000000000000000000000000..ede02ff824ae93f29fced2cdaf05ca89697b70a8 GIT binary patch literal 5772 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 literal 0 HcmV?d00001 diff --git a/routes/archive.js b/routes/archive.js index c1f19275..54e099b4 100644 --- a/routes/archive.js +++ b/routes/archive.js @@ -58,13 +58,13 @@ router.get('/:campaign/:list/:subscription', (req, res, next) => { req.flash('danger', err.message || err); return res.redirect('/'); } - +/* if (!mail && !req.user) { err = new Error('Not Found'); err.status = 404; return next(err); } - +*/ let renderAndShow = (html, renderTags) => { // rewrite links to count clicks diff --git a/routes/campaigns.js b/routes/campaigns.js index e23b897c..1a2585d9 100644 --- a/routes/campaigns.js +++ b/routes/campaigns.js @@ -90,6 +90,9 @@ router.get('/create', passport.csrfProtection, (req, res) => { case 'rss': view = 'campaigns/create-rss'; break; + case 'triggered': + view = 'campaigns/create-triggered'; + break; default: view = 'campaigns/create'; } @@ -151,6 +154,9 @@ router.get('/edit/:id', passport.csrfProtection, (req, res, next) => { let view; switch (campaign.type) { + case 4: //triggered + view = 'campaigns/edit-triggered'; + break; case 2: //rss view = 'campaigns/edit-rss'; break; @@ -159,55 +165,72 @@ router.get('/edit/:id', passport.csrfProtection, (req, res, next) => { 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) { req.flash('danger', err.message || err); return res.redirect('/'); } - if (!list) { - req.flash('danger', 'Selected list does not exist'); - res.render(view, campaign); - return; - } - fields.list(list.id, (err, fieldList) => { - if (err && !fieldList) { - 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); - }); + campaign.mergeTags = mergeTags; + res.render(view, campaign); }); }); }); @@ -298,61 +321,74 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => { return res.redirect('/campaigns'); } - lists.get(campaign.list, (err, list) => { - if (err || !campaign) { + let getList = (listId, callback) => { + 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); return res.redirect('/campaigns'); } campaign.csrfToken = req.csrfToken(); + campaign.list = list; + campaign.testUsers = testUsers; - subscriptions.listTestUsers(list.id, (err, testUsers) => { - if (err || !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.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 } - - 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) + '…'; } - 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); + return link; }); + 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'); } - lists.get(campaign.list, (err, list) => { - if (err || !campaign) { + 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, list) => { + if (err) { req.flash('danger', err && err.message || err); return res.redirect('/campaigns'); } @@ -449,8 +497,20 @@ router.get('/clicked/:id/:linkId', passport.csrfProtection, (req, res) => { return res.redirect('/campaigns'); } - lists.get(campaign.list, (err, list) => { - if (err || !campaign) { + 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, list) => { + if (err) { req.flash('danger', err && err.message || err); return res.redirect('/campaigns'); } diff --git a/routes/links.js b/routes/links.js index fdbe867a..b675c131 100644 --- a/routes/links.js +++ b/routes/links.js @@ -30,12 +30,28 @@ router.get('/:campaign/:list/:subscription', (req, res) => { res.end(trackImg); }); -router.get('/:campaign/:list/:subscription/:link', (req, res, next) => { - links.resolve(req.params.campaign, req.params.link, (err, linkId, url) => { +router.get('/:campaign/:list/:subscription/:link', (req, res) => { + + 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) { req.flash('danger', err.message || err); 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) => { if (err) { log.error('Redirect', err.stack || err); @@ -58,9 +74,8 @@ router.get('/:campaign/:list/:subscription/:link', (req, res, next) => { } if (!list) { - err = new Error('Not Found'); - err.status = 404; - return next(err); + log.error('Redirect', 'Could not resolve list for merge tags: <%s>', req.url); + return notFound(); } settings.get('serviceUrl', (err, serviceUrl) => { @@ -76,9 +91,8 @@ router.get('/:campaign/:list/:subscription/:link', (req, res, next) => { } if (!subscription) { - err = new Error('Not Found'); - err.status = 404; - return next(err); + log.error('Redirect', 'Could not resolve subscription for merge tags: <%s>', req.url); + return notFound(); } url = tools.formatMessage(serviceUrl, { diff --git a/routes/lists.js b/routes/lists.js index ccf1bed1..33bf3925 100644 --- a/routes/lists.js +++ b/routes/lists.js @@ -1,5 +1,6 @@ 'use strict'; +let config = require('config'); let openpgp = require('openpgp'); let passport = require('../lib/passport'); let express = require('express'); @@ -13,9 +14,30 @@ let htmlescape = require('escape-html'); let multer = require('multer'); let os = require('os'); let humanize = require('humanize'); -let uploads = multer({ - dest: os.tmpdir() +let mkdirp = require('mkdirp'); +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 fs = require('fs'); let moment = require('moment-timezone'); diff --git a/routes/settings.js b/routes/settings.js index 4785f6d0..993fc96a 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -11,7 +11,7 @@ let url = require('url'); 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) => { if (!req.user) { @@ -57,58 +57,51 @@ router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) = let data = tools.convertKeys(req.body); - tools.validateEmail(data.adminEmail, false, err => { - if (err) { - req.flash('danger', err && err.message || err); + let keys = []; + let values = []; + + 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'); } + let key = keys[i]; + let value = values[i]; + i++; - let keys = []; - let values = []; - - 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'); + settings.set(key, value, err => { + if (err) { + req.flash('danger', err && err.message || err); return res.redirect('/settings'); } - let key = keys[i]; - let value = values[i]; - i++; + storeSettings(); + }); + }; - settings.set(key, value, err => { - if (err) { - req.flash('danger', err && err.message || err); - return res.redirect('/settings'); - } - storeSettings(); - }); - }; - - storeSettings(); - }); + storeSettings(); }); router.post('/smtp-verify', passport.parseForm, passport.csrfProtection, (req, res) => { diff --git a/routes/triggers.js b/routes/triggers.js new file mode 100644 index 00000000..d4525147 --- /dev/null +++ b/routes/triggers.js @@ -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; diff --git a/services/sender.js b/services/sender.js index 204f24a8..09c9ffab 100644 --- a/services/sender.js +++ b/services/sender.js @@ -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')) { let cached = caches.shift('sender queue'); return returnUnsent(cached.row, cached.campaign); @@ -64,7 +119,7 @@ function findUnsent(callback) { return callback(err); } if (!rows || !rows.length) { - return callback(null, false); + return checkQueued(); } let campaign = tools.convertKeys(rows[0]); diff --git a/services/triggers.js b/services/triggers.js new file mode 100644 index 00000000..81d93331 --- /dev/null +++ b/services/triggers.js @@ -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); +}; diff --git a/setup/mailtrain.conf b/setup/mailtrain.conf index 127ff4d9..2dff9463 100644 --- a/setup/mailtrain.conf +++ b/setup/mailtrain.conf @@ -13,5 +13,5 @@ respawn limit 10 0 script cd /opt/mailtrain - exec npm start >> /var/log/mailtrain.log 2>&1 + exec node index.js >> /var/log/mailtrain.log 2>&1 end script diff --git a/setup/sql/mailtrain.sql b/setup/sql/mailtrain.sql index 09135093..a912ba3d 100644 --- a/setup/sql/mailtrain.sql +++ b/setup/sql/mailtrain.sql @@ -185,7 +185,7 @@ CREATE TABLE `settings` ( `value` text NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `key` (`key`) -) ENGINE=InnoDB AUTO_INCREMENT=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 (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','14'); +INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','15'); CREATE TABLE `subscription` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `cid` varchar(255) CHARACTER SET ascii NOT NULL, @@ -238,6 +238,31 @@ CREATE TABLE `templates` ( PRIMARY KEY (`id`), KEY `name` (`name`(191)) ) 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` ( `tz` varchar(100) NOT NULL DEFAULT '', `offset` int(11) NOT NULL DEFAULT '0', diff --git a/setup/sql/upgrade-00015.sql b/setup/sql/upgrade-00015.sql new file mode 100644 index 00000000..7c912033 --- /dev/null +++ b/setup/sql/upgrade-00015.sql @@ -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; diff --git a/views/campaigns/campaigns.hbs b/views/campaigns/campaigns.hbs index d2a70731..1828e4e1 100644 --- a/views/campaigns/campaigns.hbs +++ b/views/campaigns/campaigns.hbs @@ -9,8 +9,9 @@ Create Campaign diff --git a/views/campaigns/create-triggered.hbs b/views/campaigns/create-triggered.hbs new file mode 100644 index 00000000..04eec68a --- /dev/null +++ b/views/campaigns/create-triggered.hbs @@ -0,0 +1,110 @@ + + +

Create Triggered Campaign

+ +
+ +
+ + + +
+ +
+ +
+
+ +
+ +
+ + HTML is allowed +
+
+ +
+ +
+ +
+
+ +
+ +
+ +

+ Select a template: +

+
+ + Selecting a template creates a campaign specific copy from it +
+

+ Or alternatively use an URL as the message content source: +

+
+ + 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 +
+ +
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+
diff --git a/views/campaigns/edit-triggered.hbs b/views/campaigns/edit-triggered.hbs new file mode 100644 index 00000000..4e9b5d83 --- /dev/null +++ b/views/campaigns/edit-triggered.hbs @@ -0,0 +1,196 @@ + + +

Edit Triggered Campaign View campaign

+ +
+ +
+ + +
+ +
+ + + + +
+ + + + +
+
+ +

+ +
+ + General Settings + + +
+ +
+ +
+
+ +
+ +
+ + HTML is allowed +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +

+ +
+ + Template Settings + + + {{#if sourceUrl}} +
+ +
+ + 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 +
+
+ {{else}} + + +
+
+ +
+

+ Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: [TAG_NAME] or [TAG_NAME/fallback] where fallback is an optional + text value used when TAG_NAME is empty. +

+ + + + + + + + + + {{#each mergeTags}} + + + + + {{/each}} + +
+ Merge tag + + Description +
+ [{{key}}] + + {{value}} +
+
+
+
+ +
+ +
+ {{#if disableWysiwyg}} +
{{html}}
+ + {{else}} + + {{/if}} +
+
+ +
+ +
+ +
+
+ + {{/if}} +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
diff --git a/views/campaigns/view.hbs b/views/campaigns/view.hbs index 58e3c470..1dfdae54 100644 --- a/views/campaigns/view.hbs +++ b/views/campaigns/view.hbs @@ -33,27 +33,25 @@

-
List
-
- {{#if segment}} - - {{list.name}}: {{segment.name}} - - {{else}} - - {{list.name}} - - {{/if}} -
+ {{#if list}} +
List
+
+ {{#if segment}} + {{list.name}}: {{segment.name}} + {{else}} + {{list.name}} + {{/if}} +
-
List subscribers
-
- {{#if segment}} - {{segment.subscribers}} - {{else}} - {{list.subscribers}} - {{/if}} -
+
List subscribers
+
+ {{#if segment}} + {{segment.subscribers}} + {{else}} + {{list.subscribers}} + {{/if}} +
+ {{/if}} {{#if isRss}}
Feed URL
@@ -85,7 +83,7 @@
{{subject}}
{{/if}} - {{#if isNormal}} + {{#unless isRss}}
Preview campaign as
@@ -166,7 +164,7 @@
{{/unless}} - {{/if}} + {{/unless}}
{{#if isNormal}} @@ -313,6 +311,14 @@ {{/if}} + {{#if isTriggered}} +
+
+ This is a triggered campaign. Messages are only sent to subscribers that hit some trigger that invokes this campaign +
+
+ {{/if}} + {{#if links}}