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 b794385a..c78320f3 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]; @@ -674,38 +704,42 @@ module.exports.update = (id, updates, callback) => { } connection.query('SELECT `type`, `source_url` FROM campaigns WHERE id=? LIMIT 1', [id], (err, rows) => { + connection.release(); if (err) { - connection.release(); return callback(err); } if (!rows || !rows[0] || rows[0].type !== 2) { // if not RSS, then nothing to do here - connection.release(); return callback(null, affected); } // update seen rss entries to avoid sending old entries to subscribers feed.fetch(rows[0].source_url, (err, entries) => { if (err) { - connection.release(); return callback(err); } - let query = 'INSERT IGNORE INTO `rss` (`parent`,`guid`,`pubdate`) VALUES ' + entries.map(() => '(?,?,?)').join(','); - - values = []; - entries.forEach(entry => { - values.push(id, entry.guid, entry.date); - }); - - connection.query(query, values, err => { - connection.release(); + db.getConnection((err, connection) => { if (err) { - // too late to report as failed - log.error('RSS', err); + return callback(err); } - return callback(null, affected); + + let query = 'INSERT IGNORE INTO `rss` (`parent`,`guid`,`pubdate`) VALUES ' + entries.map(() => '(?,?,?)').join(','); + + values = []; + entries.forEach(entry => { + values.push(id, entry.guid, entry.date); + }); + + connection.query(query, values, err => { + connection.release(); + if (err) { + // too late to report as failed + log.error('RSS', err); + } + return callback(null, affected); + }); }); }); }); @@ -800,8 +834,8 @@ module.exports.pause = (id, callback) => { // campaigns marked as status=4 are paused connection.query('UPDATE campaigns SET `status`=4, `status_change`=NOW() WHERE id=? LIMIT 1', [id], err => { + connection.release(); if (err) { - connection.release(); return callback(err); } caches.cache.delete('sender queue'); @@ -874,8 +908,8 @@ module.exports.activate = (id, callback) => { // campaigns marked as status=5 are paused connection.query('UPDATE campaigns SET `status`=6, `status_change`=NOW() WHERE id=? LIMIT 1', [id], err => { + connection.release(); if (err) { - connection.release(); return callback(err); } return callback(null, true); @@ -901,8 +935,8 @@ module.exports.inactivate = (id, callback) => { // campaigns marked as status=6 are paused connection.query('UPDATE campaigns SET `status`=5, `status_change`=NOW() WHERE id=? LIMIT 1', [id], err => { + connection.release(); if (err) { - connection.release(); return callback(err); } return callback(null, true); diff --git a/lib/models/links.js b/lib/models/links.js index 64b2790c..79d09633 100644 --- a/lib/models/links.js +++ b/lib/models/links.js @@ -49,6 +49,7 @@ module.exports.countClick = (remoteIp, campaignCid, listCid, subscriptionCid, li connection.beginTransaction(err => { if (err) { + connection.release(); return callback(err); } @@ -160,6 +161,7 @@ module.exports.countOpen = (remoteIp, campaignCid, listCid, subscriptionCid, cal connection.beginTransaction(err => { if (err) { + connection.release(); return callback(err); } diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index f92392bb..c0a63825 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -357,6 +357,7 @@ module.exports.insert = (listId, meta, subscription, callback) => { connection.beginTransaction(err => { if (err) { + connection.release(); return callback(err); } @@ -682,6 +683,7 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => { } connection.beginTransaction(err => { if (err) { + connection.release(); return callback(err); } @@ -854,6 +856,7 @@ module.exports.delete = (listId, cid, callback) => { connection.beginTransaction(err => { if (err) { + connection.release(); return callback(err); } diff --git a/lib/models/triggers.js b/lib/models/triggers.js new file mode 100644 index 00000000..75b4648b --- /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 * 6; // time..NOW..time+6h, 6 hour window after trigger target to detect it + + 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) => { + connection.release(); + if (err) { + 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) { + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + let query = 'CREATE TABLE `trigger__' + id + '` LIKE `trigger`'; + connection.query(query, err => { + connection.release(); + if (err) { + return callback(err); + } + return callback(null, true); + }); + }); +} + +function removeTriggerTable(id, callback) { + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + let query = 'DROP TABLE IF EXISTS `trigger__' + id + '`'; + 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 2b527b5c..59e61d5e 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,12 @@ "compression": "^1.6.2", "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.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", @@ -54,13 +54,14 @@ "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.11.1", "nodemailer": "^2.4.2", "nodemailer-openpgp": "^1.0.2", - "npmlog": "^3.0.0", + "npmlog": "^3.1.2", "openpgp": "^2.3.2", "passport": "^0.3.2", "passport-local": "^1.0.0", diff --git a/public/mailtrain-header.png b/public/mailtrain-header.png new file mode 100644 index 00000000..ede02ff8 Binary files /dev/null and b/public/mailtrain-header.png differ 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/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..603b5ce2 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]); @@ -115,10 +170,11 @@ function findUnsent(callback) { if (!rows || !rows.length) { // everything already processed for this campaign - return connection.query('UPDATE campaigns SET `status`=3, `status_change`=NOW() WHERE id=? LIMIT 1', [campaign.id], () => { + connection.query('UPDATE campaigns SET `status`=3, `status_change`=NOW() WHERE id=? LIMIT 1', [campaign.id], () => { connection.release(); return callback(null, false); }); + return; } connection.release(); diff --git a/services/triggers.js b/services/triggers.js new file mode 100644 index 00000000..be14d81c --- /dev/null +++ b/services/triggers.js @@ -0,0 +1,114 @@ +'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) { + connection.release(); + return callback(null, trigger.id); + } + + let pos = 0; + let insertNext = () => { + if (pos >= rows.length) { + connection.release(); + 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}} +
+ +
+ + HTML code shown in the front page header section +
+
+
diff --git a/views/templates/create.hbs b/views/templates/create.hbs index a1026875..e38c3874 100644 --- a/views/templates/create.hbs +++ b/views/templates/create.hbs @@ -46,6 +46,15 @@
  • [LINK_BROWSER] – URL to preview the message in a browser
  • +
  • + [SUBSCRIPTION_ID] – Unique ID that identifies the recipient +
  • +
  • + [LIST_ID] – Unique ID that identifies the list used for this campaign +
  • +
  • + [CAMPAIGN_ID] – Unique ID that identifies current campaign +
  • In addition to that any custom field can have its own merge tag. diff --git a/views/triggers/create-select.hbs b/views/triggers/create-select.hbs new file mode 100644 index 00000000..45a46924 --- /dev/null +++ b/views/triggers/create-select.hbs @@ -0,0 +1,33 @@ +

    + +

    Create Trigger Select a list for the trigger

    + +
    + +
    + + +
    + +
    + +
    +
    + +
    +
    + +
    +
    +
    diff --git a/views/triggers/create.hbs b/views/triggers/create.hbs new file mode 100644 index 00000000..261fc59d --- /dev/null +++ b/views/triggers/create.hbs @@ -0,0 +1,162 @@ + + +

    Create Trigger

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

    {{list.name}} – {{list.subscribers}} subscribers

    +
    +
    + +
    + + Trigger rule + + +
    + +
    + +
    +
    +

    days after:

    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    + +
    + + Trigger action + + +
    +
    +
    + +
    +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    + +
    +
    + +
    +
    + + +
    diff --git a/views/triggers/edit.hbs b/views/triggers/edit.hbs new file mode 100644 index 00000000..d94664d8 --- /dev/null +++ b/views/triggers/edit.hbs @@ -0,0 +1,177 @@ + + +

    Edit Trigger Back to triggers

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

    {{list.name}} – {{list.subscribers}} subscribers

    +
    +
    + +
    + + Trigger rule + + +
    + +
    + +
    +
    +

    days after:

    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    + +
    + + Trigger action + + +
    +
    +
    + +
    +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    + +
    +
    + + +
    diff --git a/views/triggers/triggers.hbs b/views/triggers/triggers.hbs new file mode 100644 index 00000000..844fbb5a --- /dev/null +++ b/views/triggers/triggers.hbs @@ -0,0 +1,83 @@ + + +
    + Create Trigger +
    + +

    Automation Triggers

    + +
    + +
    + + + + + + + + + + + + {{#if rows}} + + + {{#each rows}} + + + + + + + + + + + {{/each}} + + {{/if}} +
    + # + + Name + + Status + + Description + + List + + Trigger + + Target Campaign + +   +
    + {{index}} + + {{name}} + + {{#if enabled}} + Enabled + {{else}} + Disabled + {{/if}} + + {{description}} + + {{listName}} + + {{{formatted}}} + + {{destCampaignName}} + + + + Edit + +
    +