resolved conflict
This commit is contained in:
commit
d697ca2fab
34 changed files with 2002 additions and 233 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
development.toml
|
||||
config/development.*
|
||||
config/production.*
|
||||
dump.rdb
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
|
||||

|
||||
|
||||
> **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.
|
||||
|
|
4
app.js
4
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);
|
||||
|
|
|
@ -25,6 +25,8 @@ 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"
|
||||
|
|
3
index.js
3
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,6 +76,7 @@ server.on('listening', () => {
|
|||
verpServer(() => {
|
||||
tzupdate(() => {
|
||||
importer(() => {
|
||||
triggers(() => {
|
||||
sender(() => {
|
||||
feedcheck(() => {
|
||||
log.info('Service', 'All services started');
|
||||
|
@ -100,4 +102,5 @@ server.on('listening', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let keys = ['name'];
|
||||
let values = [name];
|
||||
|
@ -674,21 +704,24 @@ module.exports.update = (id, updates, callback) => {
|
|||
}
|
||||
|
||||
connection.query('SELECT `type`, `source_url` FROM campaigns WHERE id=? LIMIT 1', [id], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
if (err) {
|
||||
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);
|
||||
}
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
|
@ -713,6 +746,7 @@ module.exports.update = (id, updates, callback) => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.delete = (id, callback) => {
|
||||
|
@ -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 => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
if (err) {
|
||||
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 => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
if (err) {
|
||||
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 => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, true);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
369
lib/models/triggers.js
Normal file
369
lib/models/triggers.js
Normal file
|
@ -0,0 +1,369 @@
|
|||
'use strict';
|
||||
|
||||
let tools = require('../tools');
|
||||
let db = require('../db');
|
||||
let lists = require('./lists');
|
||||
let util = require('util');
|
||||
|
||||
module.exports.defaultColumns = [{
|
||||
column: 'created',
|
||||
name: 'Sign up date',
|
||||
type: 'date'
|
||||
}, {
|
||||
column: 'latest_open',
|
||||
name: 'Latest open',
|
||||
type: 'date'
|
||||
}, {
|
||||
column: 'latest_click',
|
||||
name: 'Latest click',
|
||||
type: 'date'
|
||||
}];
|
||||
|
||||
module.exports.defaultCampaignEvents = [{
|
||||
option: 'delivered',
|
||||
name: 'Delivered'
|
||||
}, {
|
||||
option: 'opened',
|
||||
name: 'Has Opened'
|
||||
}, {
|
||||
option: 'clicked',
|
||||
name: 'Has Clicked'
|
||||
}, {
|
||||
option: 'not_opened',
|
||||
name: 'Not Opened'
|
||||
}, {
|
||||
option: 'not_clicked',
|
||||
name: 'Not Clicked'
|
||||
}];
|
||||
|
||||
let defaultColumnMap = {};
|
||||
let defaultEventMap = {};
|
||||
module.exports.defaultColumns.forEach(col => defaultColumnMap[col.column] = col.name);
|
||||
module.exports.defaultCampaignEvents.forEach(evt => defaultEventMap[evt.option] = evt.name);
|
||||
|
||||
module.exports.list = callback => {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let tableFields = [
|
||||
'`triggers`.`id` AS `id`',
|
||||
'`triggers`.`name` AS `name`',
|
||||
'`triggers`.`description` AS `description`',
|
||||
'`triggers`.`enabled` AS `enabled`',
|
||||
'`triggers`.`list` AS `list`',
|
||||
'`lists`.`name` AS `list_name`',
|
||||
'`source`.`id` AS `source_campaign`',
|
||||
'`source`.`name` AS `source_campaign_name`',
|
||||
'`dest`.`id` AS `dest_campaign`',
|
||||
'`dest`.`name` AS `dest_campaign_name`',
|
||||
'`custom_fields`.`id` AS `column_id`',
|
||||
'`triggers`.`column` AS `column`',
|
||||
'`custom_fields`.`name` AS `column_name`',
|
||||
'`triggers`.`rule` AS `rule`',
|
||||
'`triggers`.`seconds` AS `seconds`',
|
||||
'`triggers`.`created` AS `created`'
|
||||
];
|
||||
|
||||
let query = 'SELECT ' + tableFields.join(', ') + ' FROM `triggers` LEFT JOIN `campaigns` `source` ON `source`.`id`=`triggers`.`source_campaign` LEFT JOIN `campaigns` `dest` ON `dest`.`id`=`triggers`.`dest_campaign` LEFT JOIN `lists` ON `lists`.`id`=`triggers`.`list` LEFT JOIN `custom_fields` ON `custom_fields`.`list` = `triggers`.`list` AND `custom_fields`.`column`=`triggers`.`column` ORDER BY `triggers`.`name`';
|
||||
connection.query(query, (err, rows) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let triggers = (rows || []).map(tools.convertKeys).map(row => {
|
||||
if (row.rule === 'subscription' && row.column && !row.columnName) {
|
||||
row.columnName = defaultColumnMap[row.column];
|
||||
}
|
||||
|
||||
let days = Math.round(row.seconds / (24 * 3600));
|
||||
row.formatted = util.format('%s days after %s', days, row.rule === 'subscription' ? row.columnName : (util.format('%s <a href="/campaigns/view/%s">%s</a>', defaultEventMap[row.column], row.sourceCampaign, row.sourceCampaignName)));
|
||||
|
||||
return row;
|
||||
});
|
||||
return callback(null, triggers);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.getQuery = (id, callback) => {
|
||||
module.exports.get(id, (err, trigger) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let limit = 300;
|
||||
let treshold = 3600 * 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);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"schemaVersion": 14
|
||||
"schemaVersion": 15
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
BIN
public/mailtrain-header.png
Normal file
BIN
public/mailtrain-header.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.6 KiB |
|
@ -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,15 +165,13 @@ 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) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/');
|
||||
return callback(err);
|
||||
}
|
||||
if (!list) {
|
||||
req.flash('danger', 'Selected list does not exist');
|
||||
res.render(view, campaign);
|
||||
return;
|
||||
return callback(new Error('Selected list not found'));
|
||||
}
|
||||
|
||||
fields.list(list.id, (err, fieldList) => {
|
||||
|
@ -176,7 +180,7 @@ router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
|
|||
}
|
||||
|
||||
let mergeTags = [
|
||||
// indent
|
||||
// keep indentation
|
||||
{
|
||||
key: 'LINK_UNSUBSCRIBE',
|
||||
value: 'URL that points to the preferences page of the subscriber'
|
||||
|
@ -195,6 +199,15 @@ router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
|
|||
}, {
|
||||
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'
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -205,13 +218,23 @@ router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
|
|||
});
|
||||
});
|
||||
|
||||
return callback(null, list, mergeTags);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
getList(campaign.list, (err, list, mergeTags) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
campaign.mergeTags = mergeTags;
|
||||
res.render(view, campaign);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
|
@ -298,21 +321,34 @@ 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;
|
||||
|
||||
subscriptions.listTestUsers(list.id, (err, testUsers) => {
|
||||
if (err || !testUsers) {
|
||||
testUsers = [];
|
||||
}
|
||||
|
||||
campaign.testUsers = testUsers;
|
||||
|
||||
campaign.isIdling = campaign.status === 1;
|
||||
campaign.isSending = campaign.status === 2;
|
||||
campaign.isFinished = campaign.status === 3;
|
||||
|
@ -322,6 +358,7 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => {
|
|||
|
||||
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();
|
||||
|
||||
|
@ -355,7 +392,6 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/preview/:id', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,12 +57,6 @@ 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);
|
||||
return res.redirect('/settings');
|
||||
}
|
||||
|
||||
let keys = [];
|
||||
let values = [];
|
||||
|
||||
|
@ -108,7 +102,6 @@ router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) =
|
|||
};
|
||||
|
||||
storeSettings();
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/smtp-verify', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
|
|
235
routes/triggers.js
Normal file
235
routes/triggers.js
Normal file
|
@ -0,0 +1,235 @@
|
|||
'use strict';
|
||||
|
||||
let express = require('express');
|
||||
let router = new express.Router();
|
||||
let triggers = require('../lib/models/triggers');
|
||||
let campaigns = require('../lib/models/campaigns');
|
||||
let lists = require('../lib/models/lists');
|
||||
let fields = require('../lib/models/fields');
|
||||
let striptags = require('striptags');
|
||||
let passport = require('../lib/passport');
|
||||
let tools = require('../lib/tools');
|
||||
|
||||
router.all('/*', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
req.flash('danger', 'Need to be logged in to access restricted content');
|
||||
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
res.setSelectedMenu('triggers');
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
triggers.list((err, rows) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
res.render('triggers/triggers', {
|
||||
rows: rows.map((row, i) => {
|
||||
row.index = i + 1;
|
||||
row.description = striptags(row.description);
|
||||
return row;
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.get('/create-select', passport.csrfProtection, (req, res, next) => {
|
||||
let data = tools.convertKeys(req.query, {
|
||||
skip: ['layout']
|
||||
});
|
||||
|
||||
data.csrfToken = req.csrfToken();
|
||||
|
||||
lists.quicklist((err, listItems) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
data.listItems = listItems;
|
||||
|
||||
res.render('triggers/create-select', data);
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/create-select', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
if (!req.body.list) {
|
||||
req.flash('danger', 'Could not find selected list');
|
||||
return res.redirect('/triggers/create-select');
|
||||
}
|
||||
res.redirect('/triggers/' + encodeURIComponent(req.body.list) + '/create');
|
||||
});
|
||||
|
||||
|
||||
router.get('/:listId/create', passport.csrfProtection, (req, res, next) => {
|
||||
let data = tools.convertKeys(req.query, {
|
||||
skip: ['layout']
|
||||
});
|
||||
|
||||
data.csrfToken = req.csrfToken();
|
||||
data.days = Math.max(Number(data.days) || 1, 1);
|
||||
|
||||
lists.get(req.params.listId, (err, list) => {
|
||||
if (err || !list) {
|
||||
req.flash('danger', err && err.message || err || 'Could not find selected list');
|
||||
return res.redirect('/triggers/create-select');
|
||||
}
|
||||
fields.list(list.id, (err, fieldList) => {
|
||||
if (err && !fieldList) {
|
||||
fieldList = [];
|
||||
}
|
||||
|
||||
data.columns = triggers.defaultColumns.concat(fieldList.filter(field => fields.genericTypes[field.type] === 'date')).map(field => ({
|
||||
column: field.column,
|
||||
name: field.name,
|
||||
selected: data.column === field.column
|
||||
}));
|
||||
|
||||
campaigns.list(0, 300, (err, campaignList) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
data.sourceCampaigns = (campaignList || []).filter(campaign => campaign.list === list.id).map(campaign => ({
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
selected: Number(data.sourceCampaign) === campaign.id
|
||||
}));
|
||||
|
||||
data.destCampaigns = (campaignList || []).filter(campaign => campaign.list === list.id && campaign.type === 4).map(campaign => ({
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
selected: Number(data.destCampaign) === campaign.id
|
||||
}));
|
||||
|
||||
data.list = list;
|
||||
data.isSubscription = data.rule === 'subscription' || !data.rule;
|
||||
data.isCampaign = data.rule === 'campaign';
|
||||
|
||||
data.campaignOptions = triggers.defaultCampaignEvents.map(evt => ({
|
||||
option: evt.option,
|
||||
name: evt.name,
|
||||
selected: Number(data.sourceCampaign) === evt.option
|
||||
}));
|
||||
|
||||
data.isSend = true;
|
||||
|
||||
res.render('triggers/create', data);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
triggers.create(req.body, (err, id) => {
|
||||
if (err || !id) {
|
||||
req.flash('danger', err && err.message || err || 'Could not create trigger');
|
||||
if (req.body.list) {
|
||||
return res.redirect('/triggers/' + encodeURIComponent(req.body.list) + '/create?' + tools.queryParams(req.body));
|
||||
} else {
|
||||
return res.redirect('/triggers');
|
||||
}
|
||||
}
|
||||
req.flash('success', 'Trigger “' + req.body.name + '” created');
|
||||
res.redirect('/triggers');
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
|
||||
triggers.get(req.params.id, (err, trigger) => {
|
||||
if (err || !trigger) {
|
||||
req.flash('danger', err && err.message || err || 'Could not find campaign with specified ID');
|
||||
return res.redirect('/campaigns');
|
||||
}
|
||||
trigger.csrfToken = req.csrfToken();
|
||||
trigger.days = Math.round(trigger.seconds / (24 * 3600));
|
||||
|
||||
lists.get(trigger.list, (err, list) => {
|
||||
if (err || !list) {
|
||||
req.flash('danger', err && err.message || err || 'Could not find selected list');
|
||||
return res.redirect('/triggers');
|
||||
}
|
||||
fields.list(list.id, (err, fieldList) => {
|
||||
if (err && !fieldList) {
|
||||
fieldList = [];
|
||||
}
|
||||
|
||||
campaigns.list(0, 300, (err, campaignList) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
trigger.sourceCampaigns = (campaignList || []).filter(campaign => campaign.list === list.id).map(campaign => ({
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
selected: Number(trigger.sourceCampaign) === campaign.id
|
||||
}));
|
||||
|
||||
trigger.destCampaigns = (campaignList || []).filter(campaign => campaign.list === list.id && campaign.type === 4).map(campaign => ({
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
selected: Number(trigger.destCampaign) === campaign.id
|
||||
}));
|
||||
|
||||
trigger.list = list;
|
||||
trigger.isSubscription = trigger.rule === 'subscription' || !trigger.rule;
|
||||
trigger.isCampaign = trigger.rule === 'campaign';
|
||||
|
||||
trigger.columns = triggers.defaultColumns.concat(fieldList.filter(field => fields.genericTypes[field.type] === 'date')).map(field => ({
|
||||
column: field.column,
|
||||
name: field.name,
|
||||
selected: trigger.isSubscription && trigger.column === field.column
|
||||
}));
|
||||
|
||||
trigger.campaignOptions = triggers.defaultCampaignEvents.map(evt => ({
|
||||
option: evt.option,
|
||||
name: evt.name,
|
||||
selected: trigger.isCampaign && trigger.column === evt.option
|
||||
}));
|
||||
|
||||
if (trigger.rule !== 'subscription') {
|
||||
trigger.column = null;
|
||||
}
|
||||
|
||||
trigger.isSend = true;
|
||||
|
||||
res.render('triggers/edit', trigger);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
triggers.update(req.body.id, req.body, (err, updated) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/triggers/edit/' + encodeURIComponent(req.body.id));
|
||||
} else if (updated) {
|
||||
req.flash('success', 'Trigger settings updated');
|
||||
} else {
|
||||
req.flash('info', 'Trigger settings not updated');
|
||||
}
|
||||
|
||||
return res.redirect('/triggers');
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
triggers.delete(req.body.id, (err, deleted) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err);
|
||||
} else if (deleted) {
|
||||
req.flash('success', 'Trigger deleted');
|
||||
} else {
|
||||
req.flash('info', 'Could not delete specified trigger');
|
||||
}
|
||||
|
||||
return res.redirect('/triggers');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
|
@ -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();
|
||||
|
||||
|
|
114
services/triggers.js
Normal file
114
services/triggers.js
Normal file
|
@ -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);
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
59
setup/sql/upgrade-00015.sql
Normal file
59
setup/sql/upgrade-00015.sql
Normal file
|
@ -0,0 +1,59 @@
|
|||
# Header section
|
||||
# Define incrementing schema version number
|
||||
SET @schema_version = '15';
|
||||
|
||||
# table for trigger definitions
|
||||
CREATE TABLE `triggers` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) NOT NULL DEFAULT '',
|
||||
`description` text,
|
||||
`enabled` tinyint(4) unsigned NOT NULL DEFAULT '1',
|
||||
`list` int(11) unsigned NOT NULL,
|
||||
`source_campaign` int(11) unsigned DEFAULT NULL,
|
||||
`rule` varchar(255) CHARACTER SET ascii NOT NULL DEFAULT 'column',
|
||||
`column` varchar(255) CHARACTER SET ascii DEFAULT NULL,
|
||||
`seconds` int(11) NOT NULL DEFAULT '0',
|
||||
`dest_campaign` int(11) unsigned DEFAULT NULL,
|
||||
`last_check` timestamp NULL DEFAULT NULL,
|
||||
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `name` (`name`(191)),
|
||||
KEY `source_campaign` (`source_campaign`),
|
||||
KEY `dest_campaign` (`dest_campaign`),
|
||||
KEY `list` (`list`),
|
||||
KEY `column` (`column`),
|
||||
KEY `active` (`enabled`),
|
||||
KEY `last_check` (`last_check`),
|
||||
CONSTRAINT `triggers_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
# base table for triggered matches
|
||||
CREATE TABLE `trigger` (
|
||||
`list` int(11) unsigned NOT NULL,
|
||||
`subscription` int(11) unsigned NOT NULL,
|
||||
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`list`,`subscription`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
# table for yet queued messages ready to be sent
|
||||
CREATE TABLE `queued` (
|
||||
`campaign` int(11) unsigned NOT NULL,
|
||||
`list` int(11) unsigned NOT NULL,
|
||||
`subscriber` int(11) unsigned NOT NULL,
|
||||
`source` varchar(255) DEFAULT NULL,
|
||||
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`campaign`,`list`,`subscriber`),
|
||||
KEY `created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
-- {{#each tables.subscription}}
|
||||
# Adds indexes for triggers
|
||||
CREATE INDEX latest_open ON `{{this}}` (`latest_open`);
|
||||
CREATE INDEX latest_click ON `{{this}}` (`latest_click`);
|
||||
CREATE INDEX created ON `{{this}}` (`created`);
|
||||
-- {{/each}}
|
||||
|
||||
# Footer section
|
||||
LOCK TABLES `settings` WRITE;
|
||||
INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version;
|
||||
UNLOCK TABLES;
|
|
@ -11,6 +11,7 @@
|
|||
<ul class="dropdown-menu">
|
||||
<li><a href="/campaigns/create"><i class="glyphicon glyphicon-plus"></i> Normal Campaign</a></li>
|
||||
<li><a href="/campaigns/create?type=rss"><i class="glyphicon glyphicon-signal"></i> RSS Campaign</a></li>
|
||||
<li><a href="/campaigns/create?type=triggered"><i class="glyphicon glyphicon-console"></i> Triggered Campaign</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
110
views/campaigns/create-triggered.hbs
Normal file
110
views/campaigns/create-triggered.hbs
Normal file
|
@ -0,0 +1,110 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/campaigns">Campaigns</a></li>
|
||||
<li class="active">Create Triggered Campaign</li>
|
||||
</ol>
|
||||
|
||||
<h2>Create Triggered Campaign</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form-horizontal" method="post" action="/campaigns/create">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="type" value="triggered">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name" class="col-sm-2 control-label">Name</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="Campaign Name" autofocus required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="col-sm-2 control-label">Description</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" rows="3" name="description" id="description">{{description}}</textarea>
|
||||
<span class="help-block">HTML is allowed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="list" class="col-sm-2 control-label">List</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" id="list" name="list" required>
|
||||
<option value=""> –– Select –– </option>
|
||||
{{#each listItems}}
|
||||
<option value="{{id}}" {{#if selected}} selected {{/if}}>
|
||||
{{name}} <span class="text-muted"> — {{subscribers}} subscribers</span>
|
||||
</option>
|
||||
|
||||
{{#if segments}}
|
||||
<optgroup label="{{name}} segments">
|
||||
{{#each segments}}
|
||||
<option value="{{../id}}:{{id}}" {{#if selected}} selected {{/if}}>
|
||||
{{../name}}: {{name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</optgroup>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="template" class="col-sm-2 control-label">Template</label>
|
||||
<div class="col-sm-10">
|
||||
|
||||
<p class="form-control-static">
|
||||
Select a template:
|
||||
</p>
|
||||
<div>
|
||||
<select class="form-control" id="template" name="template">
|
||||
<option value=""> –– Select –– </option>
|
||||
{{#each templateItems}}
|
||||
<option value="{{id}}" {{#if selected}} selected {{/if}}>
|
||||
{{name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
<span class="help-block">Selecting a template creates a campaign specific copy from it</span>
|
||||
</div>
|
||||
<p class="form-control-static">
|
||||
Or alternatively use an URL as the message content source:
|
||||
</p>
|
||||
<div>
|
||||
<input type="url" class="form-control" name="source-url" id="source-url" value="{{sourceUrl}}" placeholder="http://example.com/message-render.php">
|
||||
<span class="help-block">If a message is sent then this URL will be POSTed to using Merge Tags as POST body. Use this if you want to generate the HTML message yourself</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="from" class="col-sm-2 control-label">Email "from name"</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="from" id="from" value="{{from}}" placeholder="This is the name your emails will come from" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="address" class="col-sm-2 control-label">Email "from" address</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="email" class="form-control" name="address" id="address" value="{{address}}" placeholder="This is the address people will send replies to" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subject" class="col-sm-2 control-label">Email "subject line"</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="subject" id="subject" value="{{subject}}" placeholder="Keep it relevant and non-spammy" required>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> Create Campaign</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
196
views/campaigns/edit-triggered.hbs
Normal file
196
views/campaigns/edit-triggered.hbs
Normal file
|
@ -0,0 +1,196 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/campaigns">Campaigns</a></li>
|
||||
{{#if parent}}
|
||||
<li><a href="/campaigns/view/{{parent.id}}">{{parent.name}}</a></li>
|
||||
{{/if}}
|
||||
<li><a href="/campaigns/view/{{id}}">{{name}}</a></li>
|
||||
<li class="active">Edit Triggered Campaign</li>
|
||||
</ol>
|
||||
|
||||
<h2>Edit Triggered Campaign <a class="btn btn-default btn-xs" href="/campaigns/view/{{id}}" role="button"><span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span> View campaign</a></h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form method="post" class="delete-form" id="campaigns-delete" action="/campaigns/delete">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
</form>
|
||||
|
||||
<form class="form-horizontal" method="post" action="/campaigns/edit">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
<input type="hidden" name="type" value="triggered" />
|
||||
|
||||
<div>
|
||||
|
||||
<!-- Nav tabs -->
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="{{#if showGeneral}}active{{/if}}"><a href="#general" aria-controls="general" role="tab" data-toggle="tab">General</a></li>
|
||||
<li role="presentation" class="{{#if showTemplate}}active{{/if}}"><a href="#template" aria-controls="template" role="tab" data-toggle="tab">Template</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane {{#if showGeneral}}active{{/if}}" id="general">
|
||||
|
||||
<p></p>
|
||||
|
||||
<fieldset>
|
||||
<legend>
|
||||
General Settings
|
||||
</legend>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name" class="col-sm-2 control-label">Name</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="Campaign Name" autofocus required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="col-sm-2 control-label">Description</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" rows="3" name="description" id="description">{{description}}</textarea>
|
||||
<span class="help-block">HTML is allowed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="list" class="col-sm-2 control-label">List</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" id="list" name="list" required>
|
||||
<option value=""> –– Select –– </option>
|
||||
{{#each listItems}}
|
||||
<option value="{{id}}" {{#if selected}} selected {{/if}}>
|
||||
{{name}} <span class="text-muted"> — {{subscribers}} subscribers</span>
|
||||
</option>
|
||||
|
||||
{{#if segments}}
|
||||
<optgroup label="{{name}} segments">
|
||||
{{#each segments}}
|
||||
<option value="{{../id}}:{{id}}" {{#if selected}} selected {{/if}}>
|
||||
{{../name}}: {{name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</optgroup>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="from" class="col-sm-2 control-label">Email "from name"</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="from" id="from" value="{{from}}" placeholder="This is the name your emails will come from" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="address" class="col-sm-2 control-label">Email "from" address</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="email" class="form-control" name="address" id="address" value="{{address}}" placeholder="This is the address people will send replies to" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subject" class="col-sm-2 control-label">Email "subject line"</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="subject" id="subject" value="{{subject}}" placeholder="Keep it relevant and non-spammy" required>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane {{#if showTemplate}}active{{/if}}" id="template">
|
||||
|
||||
<p></p>
|
||||
|
||||
<fieldset>
|
||||
<legend>
|
||||
Template Settings
|
||||
</legend>
|
||||
|
||||
{{#if sourceUrl}}
|
||||
<div class="form-group">
|
||||
<label for="source-url" class="col-sm-2 control-label">Template URL</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="url" class="form-control" name="source-url" id="source-url" value="{{sourceUrl}}" placeholder="http://example.com/message-render.php">
|
||||
<span class="help-block">If a message is sent then this URL will be POSTed to using Merge Tags as POST body. Use this if you want to generate the HTML message yourself</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<a class="btn btn-default" role="button" data-toggle="collapse" href="#mergeReference" aria-expanded="false" aria-controls="mergeReference">Merge tag reference</a>
|
||||
<div class="collapse" id="mergeReference">
|
||||
<p>
|
||||
Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>[TAG_NAME]</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional
|
||||
text value used when <code>TAG_NAME</code> is empty.
|
||||
</p>
|
||||
|
||||
<table class="table table-bordered table-condensed table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Merge tag
|
||||
</th>
|
||||
<th>
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each mergeTags}}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
[{{key}}]
|
||||
</th>
|
||||
<td>
|
||||
{{value}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="template-html" class="col-sm-2 control-label">Template content (HTML)</label>
|
||||
<div class="col-sm-10">
|
||||
{{#if disableWysiwyg}}
|
||||
<div class="code-editor" id="template-html">{{html}}</div>
|
||||
<input type="hidden" name="html">
|
||||
{{else}}
|
||||
<textarea class="form-control summernote" id="template-html" name="html" rows="8">{{html}}</textarea>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="template-text" class="col-sm-2 control-label">Template content (plaintext)</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" id="template-text" name="text" rows="10">{{text}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/if}}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="pull-right">
|
||||
<button type="submit" form="campaigns-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> Delete Campaign</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
|
@ -33,16 +33,13 @@
|
|||
<p></p>
|
||||
|
||||
<dl class="dl-horizontal">
|
||||
{{#if list}}
|
||||
<dt>List</dt>
|
||||
<dd>
|
||||
{{#if segment}}
|
||||
<a href="/lists/view/{{list.id}}?segment={{segment.id}}">
|
||||
{{list.name}}: {{segment.name}}
|
||||
</a>
|
||||
<a href="/lists/view/{{list.id}}?segment={{segment.id}}">{{list.name}}: {{segment.name}}</a>
|
||||
{{else}}
|
||||
<a href="/lists/view/{{list.id}}">
|
||||
{{list.name}}
|
||||
</a>
|
||||
<a href="/lists/view/{{list.id}}">{{list.name}}</a>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
|
@ -54,6 +51,7 @@
|
|||
{{list.subscribers}}
|
||||
{{/if}}
|
||||
</dd>
|
||||
{{/if}}
|
||||
|
||||
{{#if isRss}}
|
||||
<dt>Feed URL</dt>
|
||||
|
@ -85,7 +83,7 @@
|
|||
<dd>{{subject}}</dd>
|
||||
{{/if}}
|
||||
|
||||
{{#if isNormal}}
|
||||
{{#unless isRss}}
|
||||
|
||||
<dt>Preview campaign as</dt>
|
||||
<dd>
|
||||
|
@ -166,7 +164,7 @@
|
|||
</dd>
|
||||
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
</dl>
|
||||
|
||||
{{#if isNormal}}
|
||||
|
@ -313,6 +311,14 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if isTriggered}}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
This is a <a href="/triggers">triggered</a> campaign. Messages are only sent to subscribers that hit some trigger that invokes this campaign
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
{{#if links}}
|
||||
<div role="tabpanel" class="tab-pane {{#if showLinks}}active{{/if}}" id="links">
|
||||
|
|
|
@ -108,11 +108,11 @@
|
|||
{{#if indexPage}}
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<div class="pull-right hidden-xs">
|
||||
<img class="img-responsive" src="/mailtrain.png">
|
||||
<div class="pull-right col-md-4">
|
||||
{{{shoutout}}}
|
||||
</div>
|
||||
|
||||
<h1>Mailtrain</h1>
|
||||
<h1><img class="img-responsive" src="/mailtrain-header.png"></h1>
|
||||
<p>Self hosted newsletter app built on top of <a href="http://nodemailer.com">Nodemailer</a></p>
|
||||
<p>
|
||||
<a class="btn btn-info btn-md" href="https://github.com/andris9/mailtrain" role="button"><span class="glyphicon glyphicon-cloud-download" aria-hidden="true"></span> Source on GitHub</a>
|
||||
|
@ -121,9 +121,6 @@
|
|||
</p>
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<div class="visible-xs-block ">
|
||||
<img class="img-responsive" src="/mailtrain.png">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
<li><a href="/fields/{{id}}" role="button"><span class="glyphicon glyphicon-tasks" aria-hidden="true"></span> Custom Fields</a></li>
|
||||
<li><a href="/segments/{{id}}" role="button"><span class="glyphicon glyphicon-filter" aria-hidden="true"></span> Segments</a></li>
|
||||
<li><a href="/lists/edit/{{id}}" role="button"><span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> Edit List</a></li>
|
||||
<li><a href="/triggers/{{id}}/create" role="button"><span class="glyphicon glyphicon-console" aria-hidden="true"></span> Create Trigger</a></li>
|
||||
<li role="separator" class="divider"></li>
|
||||
<li><a href="/lists/subscription/{{id}}/add" role="button"><span class="glyphicon glyphicon-plus-sign" aria-hidden="true"></span> Add Subscriber</a></li>
|
||||
<li><a href="/lists/subscription/{{id}}/import" role="button"><span class="glyphicon glyphicon-cloud-upload" aria-hidden="true"></span> Import Subscribers</a></li>
|
||||
|
|
|
@ -55,6 +55,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="shoutout" class="col-sm-2 control-label">Frontpage shout out</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control gpg-text" rows="3" id="shoutout" name="shoutout" placeholder="">{{shoutout}}</textarea>
|
||||
<span class="help-block">HTML code shown in the front page header section</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
|
|
|
@ -46,6 +46,15 @@
|
|||
<li>
|
||||
<code>[LINK_BROWSER]</code> – URL to preview the message in a browser
|
||||
</li>
|
||||
<li>
|
||||
<code>[SUBSCRIPTION_ID]</code> – Unique ID that identifies the recipient
|
||||
</li>
|
||||
<li>
|
||||
<code>[LIST_ID]</code> – Unique ID that identifies the list used for this campaign
|
||||
</li>
|
||||
<li>
|
||||
<code>[CAMPAIGN_ID]</code> – Unique ID that identifies current campaign
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
In addition to that any custom field can have its own merge tag.
|
||||
|
|
33
views/triggers/create-select.hbs
Normal file
33
views/triggers/create-select.hbs
Normal file
|
@ -0,0 +1,33 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/triggers/">Automation Triggers</a></li>
|
||||
<li class="active">Create Trigger</li>
|
||||
</ol>
|
||||
|
||||
<h2>Create Trigger <small>Select a list for the trigger</small></h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form-horizontal" method="post" action="/triggers/create-select">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="group" class="col-sm-2 control-label">List</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" name="list" required>
|
||||
<option value=""> –– Select ––</option>
|
||||
{{#each listItems}}
|
||||
<option value="{{id}}">
|
||||
{{name}} <span class="text-muted"> — {{subscribers}} subscribers</span>
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-triangle-right"></i> Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
162
views/triggers/create.hbs
Normal file
162
views/triggers/create.hbs
Normal file
|
@ -0,0 +1,162 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/triggers/">Automation Triggers</a></li>
|
||||
<li class="active">Create Trigger</li>
|
||||
</ol>
|
||||
|
||||
<h2>Create Trigger</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form-horizontal" method="post" action="/triggers/create">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="list" value="{{list.id}}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="trigger-name" class="col-sm-2 control-label">Trigger name</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control input-lg" name="name" id="trigger-name" value="{{name}}" placeholder="Name for this trigger, eg. "Inactive subscribers"" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="trigger-description" class="col-sm-2 control-label">Description</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" id="trigger-description" name="description" rows="3" placeholder="Optional comments about this trigger">{{description}}</textarea>
|
||||
<span class="help-block">HTML is allowed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">List</label>
|
||||
<div class="col-sm-10">
|
||||
<p class="form-control-static"><a href="/lists/view/{{list.id}}">{{list.name}}</a> <span class="text-muted"> – {{list.subscribers}} subscribers</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>
|
||||
Trigger rule
|
||||
</legend>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="trigger-days" class="col-sm-2 control-label">Trigger fires</label>
|
||||
<div class="col-sm-1">
|
||||
<input type="number" class="form-control" name="days" id="trigger-days" value="{{days}}" placeholder="1" required>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<p class="form-control-static">days after:</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-offset-2 panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="rule" value="subscription" {{#if isSubscription}} checked {{/if}}> Subscription
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="column" class="col-sm-2 control-label">Event</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="column" class="form-control">
|
||||
<option value="">
|
||||
–– Select ––
|
||||
</option>
|
||||
{{#each columns}}
|
||||
<option value="{{column}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-offset-2 panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="rule" value="campaign" {{#if isCampaign}} checked {{/if}}> Campaign
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="source-campaign" class="col-sm-2 control-label">Campaign</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="source-campaign" class="form-control">
|
||||
<option value="">
|
||||
–– Select ––
|
||||
</option>
|
||||
{{#each sourceCampaigns}}
|
||||
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="campaign-option" class="col-sm-2 control-label">Event</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="campaign-option" id="campaign-option" class="form-control">
|
||||
<option value="">
|
||||
–– Select ––
|
||||
</option>
|
||||
{{#each campaignOptions}}
|
||||
<option value="{{option}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>
|
||||
Trigger action
|
||||
</legend>
|
||||
|
||||
<div class="col-sm-offset-2 panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="action" value="send" {{#if isSend}} checked {{/if}}> Send campaign
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dest-campaign" class="col-sm-2 control-label">Campaign</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="dest-campaign" class="form-control" required>
|
||||
<option value="">
|
||||
–– Select ––
|
||||
</option>
|
||||
{{#each destCampaigns}}
|
||||
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> Create Trigger</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
177
views/triggers/edit.hbs
Normal file
177
views/triggers/edit.hbs
Normal file
|
@ -0,0 +1,177 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/triggers/">Automation Triggers</a></li>
|
||||
<li class="active">Edit Trigger</li>
|
||||
</ol>
|
||||
|
||||
<h2>Edit Trigger <a class="btn btn-default btn-xs" href="/triggers/" role="button"><span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span> Back to triggers</a></h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form method="post" class="delete-form" id="triggers-delete" action="/triggers/delete">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
</form>
|
||||
|
||||
<form class="form-horizontal" method="post" action="/triggers/edit">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="trigger-name" class="col-sm-2 control-label">Trigger name</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control input-lg" name="name" id="trigger-name" value="{{name}}" placeholder="Name for this trigger, eg. "Inactive subscribers"" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="trigger-description" class="col-sm-2 control-label">Description</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" id="trigger-description" name="description" rows="3" placeholder="Optional comments about this trigger">{{description}}</textarea>
|
||||
<span class="help-block">HTML is allowed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-offset-2">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="enabled" value="1" {{#if enabled}} checked {{/if}}> Trigger is enabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">List</label>
|
||||
<div class="col-sm-10">
|
||||
<p class="form-control-static"><a href="/lists/view/{{list.id}}">{{list.name}}</a> <span class="text-muted"> – {{list.subscribers}} subscribers</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>
|
||||
Trigger rule
|
||||
</legend>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="trigger-days" class="col-sm-2 control-label">Trigger fires</label>
|
||||
<div class="col-sm-1">
|
||||
<input type="number" class="form-control" name="days" id="trigger-days" value="{{days}}" placeholder="1" required>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<p class="form-control-static">days after:</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-offset-2 panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="rule" value="subscription" {{#if isSubscription}} checked {{/if}}> Subscription
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="column" class="col-sm-2 control-label">Event</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="column" class="form-control">
|
||||
<option value="">
|
||||
–– Select ––
|
||||
</option>
|
||||
{{#each columns}}
|
||||
<option value="{{column}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-offset-2 panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="rule" value="campaign" {{#if isCampaign}} checked {{/if}}> Campaign
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="source-campaign" class="col-sm-2 control-label">Campaign</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="source-campaign" class="form-control">
|
||||
<option value="">
|
||||
–– Select ––
|
||||
</option>
|
||||
{{#each sourceCampaigns}}
|
||||
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="campaign-option" class="col-sm-2 control-label">Event</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="campaign-option" id="campaign-option" class="form-control">
|
||||
<option value="">
|
||||
–– Select ––
|
||||
</option>
|
||||
{{#each campaignOptions}}
|
||||
<option value="{{option}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>
|
||||
Trigger action
|
||||
</legend>
|
||||
|
||||
<div class="col-sm-offset-2 panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="action" value="send" {{#if isSend}} checked {{/if}}> Send campaign
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dest-campaign" class="col-sm-2 control-label">Campaign</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="dest-campaign" class="form-control" required>
|
||||
<option value="">
|
||||
–– Select ––
|
||||
</option>
|
||||
{{#each destCampaigns}}
|
||||
<option value="{{id}}" {{#if selected}} selected {{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="pull-right">
|
||||
<button type="submit" form="triggers-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> Delete Trigger</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> Update</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
83
views/triggers/triggers.hbs
Normal file
83
views/triggers/triggers.hbs
Normal file
|
@ -0,0 +1,83 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li class="active">Automation Triggers</li>
|
||||
</ol>
|
||||
|
||||
<div class="pull-right">
|
||||
<a class="btn btn-primary" href="/triggers/create-select" role="button"><i class="glyphicon glyphicon-plus"></i> Create Trigger</a>
|
||||
</div>
|
||||
|
||||
<h2>Automation Triggers</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="0,1,1,0,1,0,1,0">
|
||||
<thead>
|
||||
<th class="col-md-1">
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Status
|
||||
</th>
|
||||
<th>
|
||||
Description
|
||||
</th>
|
||||
<th>
|
||||
List
|
||||
</th>
|
||||
<th>
|
||||
Trigger
|
||||
</th>
|
||||
<th>
|
||||
Target Campaign
|
||||
</th>
|
||||
<th class="col-md-1">
|
||||
|
||||
</th>
|
||||
</thead>
|
||||
{{#if rows}}
|
||||
<tbody>
|
||||
|
||||
{{#each rows}}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
{{index}}
|
||||
</th>
|
||||
<td>
|
||||
<span class="glyphicon glyphicon-console" aria-hidden="true"></span> {{name}}
|
||||
</td>
|
||||
<td>
|
||||
{{#if enabled}}
|
||||
<span class="label label-success">Enabled</span>
|
||||
{{else}}
|
||||
<span class="label label-default">Disabled</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
{{description}}
|
||||
</td>
|
||||
<td class="text-info">
|
||||
<a href="/lists/view/{{list}}">{{listName}}</a>
|
||||
</td>
|
||||
<td class="text-info">
|
||||
{{{formatted}}}
|
||||
</td>
|
||||
<td class="text-info">
|
||||
<a href="/campaigns/view/{{destCampaign}}">{{destCampaignName}}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>
|
||||
<a href="/triggers/edit/{{id}}">
|
||||
Edit
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
{{/if}}
|
||||
</table>
|
||||
</div>
|
Loading…
Add table
Add a link
Reference in a new issue