resolved conflict

This commit is contained in:
Andris Reinman 2016-06-22 15:54:53 +03:00
commit d697ca2fab
34 changed files with 2002 additions and 233 deletions

3
.gitignore vendored
View file

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

View file

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

4
app.js
View file

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

View file

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

View file

@ -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');
@ -101,3 +103,4 @@ server.on('listening', () => {
});
});
});
});

View file

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

View file

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

View file

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

@ -0,0 +1,369 @@
'use strict';
let tools = require('../tools');
let db = require('../db');
let lists = require('./lists');
let util = require('util');
module.exports.defaultColumns = [{
column: 'created',
name: 'Sign up date',
type: 'date'
}, {
column: 'latest_open',
name: 'Latest open',
type: 'date'
}, {
column: 'latest_click',
name: 'Latest click',
type: 'date'
}];
module.exports.defaultCampaignEvents = [{
option: 'delivered',
name: 'Delivered'
}, {
option: 'opened',
name: 'Has Opened'
}, {
option: 'clicked',
name: 'Has Clicked'
}, {
option: 'not_opened',
name: 'Not Opened'
}, {
option: 'not_clicked',
name: 'Not Clicked'
}];
let defaultColumnMap = {};
let defaultEventMap = {};
module.exports.defaultColumns.forEach(col => defaultColumnMap[col.column] = col.name);
module.exports.defaultCampaignEvents.forEach(evt => defaultEventMap[evt.option] = evt.name);
module.exports.list = callback => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let tableFields = [
'`triggers`.`id` AS `id`',
'`triggers`.`name` AS `name`',
'`triggers`.`description` AS `description`',
'`triggers`.`enabled` AS `enabled`',
'`triggers`.`list` AS `list`',
'`lists`.`name` AS `list_name`',
'`source`.`id` AS `source_campaign`',
'`source`.`name` AS `source_campaign_name`',
'`dest`.`id` AS `dest_campaign`',
'`dest`.`name` AS `dest_campaign_name`',
'`custom_fields`.`id` AS `column_id`',
'`triggers`.`column` AS `column`',
'`custom_fields`.`name` AS `column_name`',
'`triggers`.`rule` AS `rule`',
'`triggers`.`seconds` AS `seconds`',
'`triggers`.`created` AS `created`'
];
let query = 'SELECT ' + tableFields.join(', ') + ' FROM `triggers` LEFT JOIN `campaigns` `source` ON `source`.`id`=`triggers`.`source_campaign` LEFT JOIN `campaigns` `dest` ON `dest`.`id`=`triggers`.`dest_campaign` LEFT JOIN `lists` ON `lists`.`id`=`triggers`.`list` LEFT JOIN `custom_fields` ON `custom_fields`.`list` = `triggers`.`list` AND `custom_fields`.`column`=`triggers`.`column` ORDER BY `triggers`.`name`';
connection.query(query, (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let triggers = (rows || []).map(tools.convertKeys).map(row => {
if (row.rule === 'subscription' && row.column && !row.columnName) {
row.columnName = defaultColumnMap[row.column];
}
let days = Math.round(row.seconds / (24 * 3600));
row.formatted = util.format('%s days after %s', days, row.rule === 'subscription' ? row.columnName : (util.format('%s <a href="/campaigns/view/%s">%s</a>', defaultEventMap[row.column], row.sourceCampaign, row.sourceCampaignName)));
return row;
});
return callback(null, triggers);
});
});
};
module.exports.getQuery = (id, callback) => {
module.exports.get(id, (err, trigger) => {
if (err) {
return callback(err);
}
let limit = 300;
let treshold = 3600 * 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);
});
});
}

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -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,6 +218,17 @@ 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);
});
@ -212,7 +236,6 @@ router.get('/edit/:id', passport.csrfProtection, (req, res, next) => {
});
});
});
});
router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
campaigns.update(req.body.id, req.body, (err, updated) => {
@ -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();
@ -356,7 +393,6 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => {
});
});
});
});
router.post('/preview/:id', passport.parseForm, passport.csrfProtection, (req, res) => {
let campaign = req.body.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');
}

View file

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

View file

@ -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 = [];
@ -109,7 +103,6 @@ router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) =
storeSettings();
});
});
router.post('/smtp-verify', passport.parseForm, passport.csrfProtection, (req, res) => {
settings.list((err, configItems) => {

235
routes/triggers.js Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@
<li><a href="/fields/{{id}}" role="button"><span class="glyphicon glyphicon-tasks" aria-hidden="true"></span> Custom Fields</a></li>
<li><a href="/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>

View file

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

View file

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

View file

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

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

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

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

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

View file

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