commit
06ef8b878f
18 changed files with 350 additions and 161 deletions
|
@ -37,6 +37,7 @@ Subscribe to Mailtrain Newsletter [here](http://mailtrain.org/subscription/EysIv
|
|||
## Upgrade
|
||||
|
||||
* Replace old files with new ones by running in the Mailtrain folder `git pull origin master`
|
||||
* Run `npm install --production` in the Mailtrain folder
|
||||
|
||||
## Using environment variables
|
||||
|
||||
|
|
2
index.js
2
index.js
|
@ -34,7 +34,7 @@ let server = http.createServer(app);
|
|||
// Check if database needs upgrading before starting the server
|
||||
dbcheck(err => {
|
||||
if (err) {
|
||||
log.error('DB', err);
|
||||
log.error('DB', err.message || err);
|
||||
return process.exit(1);
|
||||
}
|
||||
/**
|
||||
|
|
|
@ -21,6 +21,12 @@ let db = mysql.createPool(mysqlConfig);
|
|||
function listTables(callback) {
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
if(err.code === 'ER_ACCESS_DENIED_ERROR'){
|
||||
err = new Error('Could not access the database. Check MySQL config and authentication credentials');
|
||||
}
|
||||
if(err.code === 'ECONNREFUSED' || err.code === 'PROTOCOL_SEQUENCE_TIMEOUT'){
|
||||
err = new Error('Could not connect to the database. Check MySQL host and port configuration');
|
||||
}
|
||||
return callback(err);
|
||||
}
|
||||
let query = 'SHOW TABLES';
|
||||
|
|
|
@ -9,6 +9,7 @@ let Handlebars = require('handlebars');
|
|||
let fs = require('fs');
|
||||
let path = require('path');
|
||||
let templates = new Map();
|
||||
let htmlToText = require('html-to-text');
|
||||
|
||||
module.exports.transport = false;
|
||||
|
||||
|
@ -54,6 +55,10 @@ module.exports.sendMail = (mail, template, callback) => {
|
|||
|
||||
if (textRenderer) {
|
||||
mail.text = textRenderer(template.data || {});
|
||||
} else if (mail.html) {
|
||||
mail.text = htmlToText.fromString(mail.html, {
|
||||
wordwrap: 130
|
||||
});
|
||||
}
|
||||
|
||||
module.exports.transport.sendMail(mail, callback);
|
||||
|
|
|
@ -8,7 +8,7 @@ let segments = require('./segments');
|
|||
let subscriptions = require('./subscriptions');
|
||||
let shortid = require('shortid');
|
||||
|
||||
let allowedKeys = ['description', 'from', 'address', 'subject', 'template', 'list', 'segment', 'html', 'text'];
|
||||
let allowedKeys = ['description', 'from', 'address', 'subject', 'template', 'template_url', 'list', 'segment', 'html', 'text'];
|
||||
|
||||
module.exports.list = (start, limit, callback) => {
|
||||
db.getConnection((err, connection) => {
|
||||
|
@ -297,7 +297,7 @@ module.exports.delete = (id, callback) => {
|
|||
});
|
||||
};
|
||||
|
||||
module.exports.send = (id, callback) => {
|
||||
module.exports.send = (id, scheduled, callback) => {
|
||||
module.exports.get(id, false, (err, campaign) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
|
@ -312,8 +312,18 @@ module.exports.send = (id, callback) => {
|
|||
return callback(err);
|
||||
}
|
||||
|
||||
let query;
|
||||
let values;
|
||||
if (scheduled) {
|
||||
query = 'UPDATE campaigns SET `status`=2, `scheduled`=?, `status_change`=NOW() WHERE id=? LIMIT 1';
|
||||
values = [scheduled, id];
|
||||
} else {
|
||||
query = 'UPDATE campaigns SET `status`=2, `status_change`=NOW() WHERE id=? LIMIT 1';
|
||||
values = [id];
|
||||
}
|
||||
|
||||
// campaigns marked as status=2 should be picked up by the sending processes
|
||||
connection.query('UPDATE campaigns SET `status`=2, `status_change`=NOW() WHERE id=? LIMIT 1', [id], err => {
|
||||
connection.query(query, values, err => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
|
@ -357,7 +367,7 @@ module.exports.reset = (id, callback) => {
|
|||
return callback(err);
|
||||
}
|
||||
|
||||
if (campaign.status !== 3) {
|
||||
if (campaign.status !== 3 && !(campaign.status === 2 && campaign.scheduled > new Date())) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
|
|
23
lib/tools.js
23
lib/tools.js
|
@ -15,7 +15,8 @@ module.exports = {
|
|||
createSlug,
|
||||
updateMenu,
|
||||
validateEmail,
|
||||
formatMessage
|
||||
formatMessage,
|
||||
getMessageLinks
|
||||
};
|
||||
|
||||
function toDbKey(key) {
|
||||
|
@ -147,17 +148,23 @@ function validateEmail(address, checkBlocked, callback) {
|
|||
});
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
function formatMessage(serviceUrl, campaign, list, subscription, message, filter) {
|
||||
filter = typeof filter === 'function' ? filter : (str => str);
|
||||
|
||||
let links = getMessageLinks(serviceUrl, campaign, list, subscription);
|
||||
|
||||
let getValue = key => {
|
||||
switch ((key || '').toString().toUpperCase().trim()) {
|
||||
case 'LINK_UNSUBSCRIBE':
|
||||
return urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?auto=yes&c=' + campaign.cid);
|
||||
case 'LINK_PREFERENCES':
|
||||
return urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid);
|
||||
case 'LINK_BROWSER':
|
||||
return urllib.resolve(serviceUrl, '/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid);
|
||||
key = (key || '').toString().toUpperCase().trim();
|
||||
if (links.hasOwnProperty(key)) {
|
||||
return links[key];
|
||||
}
|
||||
if (subscription.mergeTags.hasOwnProperty(key)) {
|
||||
return subscription.mergeTags[key];
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"schemaVersion": 2
|
||||
"schemaVersion": 4
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
"geoip-ultralight": "^0.1.3",
|
||||
"handlebars": "^4.0.5",
|
||||
"hbs": "^4.0.0",
|
||||
"html-to-text": "^2.1.0",
|
||||
"humanize": "0.0.9",
|
||||
"isemail": "^2.1.0",
|
||||
"morgan": "^1.7.0",
|
||||
|
|
|
@ -7,6 +7,7 @@ let lists = require('../lib/models/lists');
|
|||
let subscriptions = require('../lib/models/subscriptions');
|
||||
let tools = require('../lib/tools');
|
||||
let express = require('express');
|
||||
let request = require('request');
|
||||
let router = new express.Router();
|
||||
|
||||
router.get('/:campaign/:list/:subscription', (req, res, next) => {
|
||||
|
@ -64,21 +65,45 @@ router.get('/:campaign/:list/:subscription', (req, res, next) => {
|
|||
return next(err);
|
||||
}
|
||||
|
||||
// rewrite links to count clicks
|
||||
links.updateLinks(campaign, list, subscription, serviceUrl, campaign.html, (err, html) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/');
|
||||
}
|
||||
let renderAndShow = (html, renderTags) => {
|
||||
|
||||
res.render('archive/view', {
|
||||
layout: 'archive/layout',
|
||||
message: tools.formatMessage(serviceUrl, campaign, list, subscription, html),
|
||||
campaign,
|
||||
list,
|
||||
subscription
|
||||
// rewrite links to count clicks
|
||||
links.updateLinks(campaign, list, subscription, serviceUrl, html, (err, html) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
res.render('archive/view', {
|
||||
layout: 'archive/layout',
|
||||
message: renderTags ? tools.formatMessage(serviceUrl, campaign, list, subscription, html) : html,
|
||||
campaign,
|
||||
list,
|
||||
subscription
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (campaign.templateUrl) {
|
||||
let form = tools.getMessageLinks(serviceUrl, campaign, list, subscription);
|
||||
Object.keys(subscription.mergeTags).forEach(key => {
|
||||
form[key] = subscription.mergeTags[key];
|
||||
});
|
||||
request.post({
|
||||
url: campaign.templateUrl,
|
||||
form
|
||||
}, (err, httpResponse, body) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
if (httpResponse.statusCode !== 200) {
|
||||
return next(new Error('Received status code ' + httpResponse.statusCode + ' from ' + campaign.templateUrl));
|
||||
}
|
||||
renderAndShow(body && body.toString(), false);
|
||||
});
|
||||
} else {
|
||||
renderAndShow(campaign.html, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -38,7 +38,11 @@ router.get('/', (req, res) => {
|
|||
row.statusText = 'Idling';
|
||||
break;
|
||||
case 2:
|
||||
row.statusText = 'Sending';
|
||||
if (row.scheduled && row.scheduled > new Date()) {
|
||||
row.statusText = 'Scheduled';
|
||||
} else {
|
||||
row.statusText = 'Sending';
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
row.statusText = 'Finished';
|
||||
|
@ -228,6 +232,8 @@ router.get('/view/:id', passport.csrfProtection, (req, res) => {
|
|||
campaign.isFinished = campaign.status === 3;
|
||||
campaign.isPaused = campaign.status === 4;
|
||||
|
||||
campaign.isScheduled = campaign.scheduled && campaign.scheduled > new Date();
|
||||
|
||||
campaign.openRate = campaign.delivered ? Math.round((campaign.opened / campaign.delivered) * 100) : 0;
|
||||
campaign.clicksRate = campaign.delivered ? Math.round((campaign.clicks / campaign.delivered) * 100) : 0;
|
||||
|
||||
|
@ -251,7 +257,11 @@ router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) =
|
|||
});
|
||||
|
||||
router.post('/send', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
campaigns.send(req.body.id, (err, scheduled) => {
|
||||
let delayHours = Math.max(Number(req.body['delay-hours']) || 0, 0);
|
||||
let delayMinutes = Math.max(Number(req.body['delay-minutes']) || 0, 0);
|
||||
let scheduled = new Date(Date.now() + delayHours * 3600 * 1000 + delayMinutes * 60 * 1000);
|
||||
|
||||
campaigns.send(req.body.id, scheduled, (err, scheduled) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err);
|
||||
} else if (scheduled) {
|
||||
|
@ -264,6 +274,20 @@ router.post('/send', passport.parseForm, passport.csrfProtection, (req, res) =>
|
|||
});
|
||||
});
|
||||
|
||||
router.post('/resume', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
campaigns.send(req.body.id, false, (err, scheduled) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err);
|
||||
} else if (scheduled) {
|
||||
req.flash('success', 'Sending resumed');
|
||||
} else {
|
||||
req.flash('info', 'Could not resume sending');
|
||||
}
|
||||
|
||||
return res.redirect('/campaigns/view/' + encodeURIComponent(req.body.id));
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/reset', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
campaigns.reset(req.body.id, (err, reset) => {
|
||||
if (err) {
|
||||
|
|
|
@ -13,6 +13,8 @@ let settings = require('../lib/models/settings');
|
|||
let links = require('../lib/models/links');
|
||||
let shortid = require('shortid');
|
||||
let url = require('url');
|
||||
let htmlToText = require('html-to-text');
|
||||
let request = require('request');
|
||||
|
||||
function findUnsent(callback) {
|
||||
db.getConnection((err, connection) => {
|
||||
|
@ -20,7 +22,7 @@ function findUnsent(callback) {
|
|||
return callback(err);
|
||||
}
|
||||
|
||||
let query = 'SELECT id, list, segment FROM campaigns WHERE status=? LIMIT 1';
|
||||
let query = 'SELECT `id`, `list`, `segment` FROM `campaigns` WHERE `status`=? AND (`scheduled` IS NULL OR `scheduled` <= NOW()) LIMIT 1';
|
||||
connection.query(query, [2], (err, rows) => {
|
||||
if (err) {
|
||||
connection.release();
|
||||
|
@ -149,73 +151,102 @@ function formatMessage(message, callback) {
|
|||
}
|
||||
});
|
||||
|
||||
links.updateLinks(campaign, list, message.subscription, configItems.serviceUrl, campaign.html, (err, html) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
let renderAndSend = (html, text, renderTags) => {
|
||||
links.updateLinks(campaign, list, message.subscription, configItems.serviceUrl, html, (err, html) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
// replace data: images with embedded attachments
|
||||
let attachments = [];
|
||||
html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
|
||||
let cid = shortid.generate() + '-attachments@' + campaign.address.split('@').pop();
|
||||
attachments.push({
|
||||
path: dataUri,
|
||||
cid
|
||||
// replace data: images with embedded attachments
|
||||
let attachments = [];
|
||||
html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
|
||||
let cid = shortid.generate() + '-attachments@' + campaign.address.split('@').pop();
|
||||
attachments.push({
|
||||
path: dataUri,
|
||||
cid
|
||||
});
|
||||
return prefix + 'cid:' + cid;
|
||||
});
|
||||
return prefix + 'cid:' + cid;
|
||||
});
|
||||
|
||||
let campaignAddress = [campaign.cid, list.cid, message.subscription.cid].join('.');
|
||||
let campaignAddress = [campaign.cid, list.cid, message.subscription.cid].join('.');
|
||||
|
||||
return callback(null, {
|
||||
from: {
|
||||
name: campaign.from,
|
||||
address: campaign.address
|
||||
},
|
||||
xMailer: 'Mailtrain Mailer (+https://mailtrain.org)',
|
||||
to: {
|
||||
name: [].concat(message.subscription.firstName || []).concat(message.subscription.lastName || []).join(' '),
|
||||
address: message.subscription.email
|
||||
},
|
||||
sender: useVerp ? campaignAddress + '@' + configItems.verpHostname : false,
|
||||
let renderedHtml = renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html) : html;
|
||||
|
||||
envelope: useVerp ? {
|
||||
from: campaignAddress + '@' + configItems.verpHostname,
|
||||
to: message.subscription.email
|
||||
} : false,
|
||||
let renderedText = (text || '').trim() ? (renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, text) : text) : htmlToText.fromString(renderedHtml, {
|
||||
wordwrap: 130
|
||||
});
|
||||
|
||||
headers: {
|
||||
'x-fbl': campaignAddress,
|
||||
// custom header for SparkPost
|
||||
'x-msys-api': JSON.stringify({
|
||||
campaign_id: campaignAddress
|
||||
}),
|
||||
// custom header for SendGrid
|
||||
'x-smtpapi': JSON.stringify({
|
||||
unique_args: {
|
||||
return callback(null, {
|
||||
from: {
|
||||
name: campaign.from,
|
||||
address: campaign.address
|
||||
},
|
||||
xMailer: 'Mailtrain Mailer (+https://mailtrain.org)',
|
||||
to: {
|
||||
name: [].concat(message.subscription.firstName || []).concat(message.subscription.lastName || []).join(' '),
|
||||
address: message.subscription.email
|
||||
},
|
||||
sender: useVerp ? campaignAddress + '@' + configItems.verpHostname : false,
|
||||
|
||||
envelope: useVerp ? {
|
||||
from: campaignAddress + '@' + configItems.verpHostname,
|
||||
to: message.subscription.email
|
||||
} : false,
|
||||
|
||||
headers: {
|
||||
'x-fbl': campaignAddress,
|
||||
// custom header for SparkPost
|
||||
'x-msys-api': JSON.stringify({
|
||||
campaign_id: campaignAddress
|
||||
}),
|
||||
// custom header for SendGrid
|
||||
'x-smtpapi': JSON.stringify({
|
||||
unique_args: {
|
||||
campaign_id: campaignAddress
|
||||
}
|
||||
}),
|
||||
// custom header for Mailgun
|
||||
'x-mailgun-variables': JSON.stringify({
|
||||
campaign_id: campaignAddress
|
||||
}),
|
||||
'List-ID': {
|
||||
prepared: true,
|
||||
value: '"' + list.name.replace(/[^a-z0-9\s'.,\-]/g, '').trim() + '" <' + list.cid + '.' + (url.parse(configItems.serviceUrl).hostname || 'localhost') + '>'
|
||||
}
|
||||
}),
|
||||
// custom header for Mailgun
|
||||
'x-mailgun-variables': JSON.stringify({
|
||||
campaign_id: campaignAddress
|
||||
}),
|
||||
'List-ID': {
|
||||
prepared: true,
|
||||
value: '"' + list.name.replace(/[^a-z0-9\s'.,\-]/g, '').trim() + '" <' + list.cid + '.' + (url.parse(configItems.serviceUrl).hostname || 'localhost') + '>'
|
||||
}
|
||||
},
|
||||
list: {
|
||||
unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid + '?auto=yes')
|
||||
},
|
||||
subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject),
|
||||
html: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html),
|
||||
text: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.text),
|
||||
},
|
||||
list: {
|
||||
unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid + '?auto=yes')
|
||||
},
|
||||
subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject),
|
||||
html: renderedHtml,
|
||||
text: renderedText,
|
||||
|
||||
attachments,
|
||||
encryptionKeys
|
||||
attachments,
|
||||
encryptionKeys
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (campaign.templateUrl) {
|
||||
let form = tools.getMessageLinks(configItems.serviceUrl, campaign, list, message.subscription);
|
||||
Object.keys(message.subscription.mergeTags).forEach(key => {
|
||||
form[key] = message.subscription.mergeTags[key];
|
||||
});
|
||||
request.post({
|
||||
url: campaign.templateUrl,
|
||||
form
|
||||
}, (err, httpResponse, body) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (httpResponse.statusCode !== 200) {
|
||||
return callback(new Error('Received status code ' + httpResponse.statusCode + ' from ' + campaign.templateUrl));
|
||||
}
|
||||
renderAndSend(body && body.toString(), '', false);
|
||||
});
|
||||
} else {
|
||||
renderAndSend(campaign.html, campaign.text, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
12
setup/sql/upgrade-00003.sql
Normal file
12
setup/sql/upgrade-00003.sql
Normal file
|
@ -0,0 +1,12 @@
|
|||
# Header section
|
||||
# Define incrementing schema version number
|
||||
SET @schema_version = '3';
|
||||
|
||||
# Adds new column 'scheduled' to campaigns table. Indicates when the sending should actually start
|
||||
ALTER TABLE `campaigns` ADD COLUMN `scheduled` timestamp NULL DEFAULT NULL AFTER `status`;
|
||||
CREATE INDEX schedule_index ON `campaigns` (`scheduled`);
|
||||
|
||||
# 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;
|
12
setup/sql/upgrade-00004.sql
Normal file
12
setup/sql/upgrade-00004.sql
Normal file
|
@ -0,0 +1,12 @@
|
|||
# Header section
|
||||
# Define incrementing schema version number
|
||||
SET @schema_version = '4';
|
||||
|
||||
# Adds new column 'template_url' to campaigns table
|
||||
# Indicates that this campaign should fetch message content from this URL
|
||||
ALTER TABLE `campaigns` ADD COLUMN `template_url` varchar(255) CHARACTER SET ascii DEFAULT NULL AFTER `template`;
|
||||
|
||||
# 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;
|
|
@ -53,15 +53,29 @@
|
|||
<div class="form-group">
|
||||
<label for="template" class="col-sm-2 control-label">Template</label>
|
||||
<div class="col-sm-10">
|
||||
<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">Not required. Creates a campaign specific copy from a template that you can later edit</span>
|
||||
|
||||
<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="template-url" id="template-url" value="{{templateUrl}}" 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>
|
||||
|
||||
|
|
|
@ -106,60 +106,73 @@
|
|||
Template Settings
|
||||
</legend>
|
||||
|
||||
<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>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>[FIRST_NAME]</code> – first name of the subcriber
|
||||
</li>
|
||||
<li>
|
||||
<code>[LAST_NAME]</code> – last name of the subcriber
|
||||
</li>
|
||||
<li>
|
||||
<code>[FULL_NAME]</code> – first and last names of the subcriber joined
|
||||
</li>
|
||||
<li>
|
||||
<code>[LINK_UNSUBSCRIBE]</code> – URL that points to the preferences page of the subscriber
|
||||
</li>
|
||||
<li>
|
||||
<code>[LINK_PREFERENCES]</code> – URL that points to the unsubscribe page
|
||||
</li>
|
||||
<li>
|
||||
<code>[LINK_BROWSER]</code> – URL to preview the message in a browser
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
In addition to that any custom field can have its own merge tag.
|
||||
</p>
|
||||
{{#if templateUrl}}
|
||||
<div class="form-group">
|
||||
<label for="template-url" class="col-sm-2 control-label">Template URL</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="url" class="form-control" name="template-url" id="template-url" value="{{templateUrl}}" 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>
|
||||
{{else}}
|
||||
|
||||
<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 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>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>[FIRST_NAME]</code> – first name of the subcriber
|
||||
</li>
|
||||
<li>
|
||||
<code>[LAST_NAME]</code> – last name of the subcriber
|
||||
</li>
|
||||
<li>
|
||||
<code>[FULL_NAME]</code> – first and last names of the subcriber joined
|
||||
</li>
|
||||
<li>
|
||||
<code>[LINK_UNSUBSCRIBE]</code> – URL that points to the preferences page of the subscriber
|
||||
</li>
|
||||
<li>
|
||||
<code>[LINK_PREFERENCES]</code> – URL that points to the unsubscribe page
|
||||
</li>
|
||||
<li>
|
||||
<code>[LINK_BROWSER]</code> – URL to preview the message in a browser
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
In addition to that any custom field can have its own merge tag.
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
@ -85,10 +85,28 @@
|
|||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
{{#if isIdling}}
|
||||
<form class="form-horizontal confirm-submit" data-confirm-message="Are you sure? This action would start sending messages to the selected list" method="post" action="/campaigns/send">
|
||||
<form class="form-inline confirm-submit" data-confirm-message="Are you sure? This action would start sending messages to the selected list" method="post" action="/campaigns/send">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
|
||||
<div class="pull-right">
|
||||
<div class="form-group">
|
||||
<p class="form-control-static">Delay sending</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" name="delay-hours" id="delay-hours" placeholder="0">
|
||||
<div class="input-group-addon"> hours</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" name="delay-minutes" id="delay-minutes" placeholder="0">
|
||||
<div class="input-group-addon"> minutes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success"><span class="glyphicon glyphicon-send" aria-hidden="true"></span> Send to
|
||||
{{#if segment}}
|
||||
{{segment.subscribers}}
|
||||
|
@ -101,21 +119,34 @@
|
|||
{{#if isSending}}
|
||||
<!-- Indicate that this page needs refreshing after 20s. -->
|
||||
<div class="page-refresh" data-interval="20"></div>
|
||||
<div class="pull-right">
|
||||
<form class="form-horizontal" method="post" action="/campaigns/pause">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
{{#if isScheduled}}
|
||||
<div class="pull-right">
|
||||
<form class="form-horizontal confirm-submit" data-confirm-message="Are you sure? This action would reset scheduling" method="post" action="/campaigns/reset">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
|
||||
<button type="submit" class="btn btn-info"><span class="glyphicon glyphicon-pause" aria-hidden="true"></span> Pause</a>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<h4>Sending…</h4>
|
||||
<button type="submit" class="btn btn-danger"><span class="glyphicon glyphicon-remove" aria-hidden="true"></span> Cancel</a>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<h4>Sending scheduled <span class="datestring text-info" data-date="{{scheduled}}" title="{{scheduled}}">{{scheduled}}</span></h4>
|
||||
{{else}}
|
||||
<div class="pull-right">
|
||||
<form class="form-horizontal" method="post" action="/campaigns/pause">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
|
||||
<button type="submit" class="btn btn-info"><span class="glyphicon glyphicon-pause" aria-hidden="true"></span> Pause</a>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<h4>Sending…</h4>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if isPaused}}
|
||||
<div class="pull-right">
|
||||
<form class="form-horizontal confirm-submit" data-confirm-message="Are you sure? This action would resume sending messages to the selected list" method="post" action="/campaigns/send">
|
||||
<form class="form-horizontal confirm-submit" data-confirm-message="Are you sure? This action would resume sending messages to the selected list" method="post" action="/campaigns/resume">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
|
||||
|
|
|
@ -21,9 +21,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h2>Open source</h2>
|
||||
|
@ -35,7 +32,7 @@
|
|||
</div>
|
||||
<div class="col-md-4">
|
||||
<h2>On the roadmap</h2>
|
||||
<p>Reports, API access and more…</p>
|
||||
<p>Better reports, API access, campaign automation and more…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
<li><a href="{{url}}">{{title}}</a></li>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
<li><a href="https://mailtrain.wordpress.com/"><span class="glyphicon glyphicon-share-alt" aria-hidden="true"></span> Blog</a></li>
|
||||
</ul>
|
||||
|
||||
{{#if user }}
|
||||
|
@ -96,7 +97,6 @@
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
<!-- Main jumbotron for a primary marketing message or call to action -->
|
||||
|
||||
{{#if indexPage}}
|
||||
|
|
Loading…
Reference in a new issue