fixed conflicts

This commit is contained in:
Andris Reinman 2017-03-21 10:16:42 +02:00
commit 6b87a9711f
74 changed files with 3540 additions and 34844 deletions

View file

@ -1,5 +1,12 @@
# Changelog
## 1.23.0 2017-03-19
* Fixed security issue where description tags were able to include script tags. Reported by Andreas Lindh. Fixed with [ae6affda](https://github.com/andris9/mailtrain/commit/ae6affda8193f034e06f7e095ee23821a83d5190)
* Fixed security issue where templates that looked like file paths loaded content from arbitrary files. Reported by Andreas Lindh. Fixed with [0879fa41](https://github.com/andris9/mailtrain/commit/0879fa412a2d4a417aeca5cd5092a8f86531e7ef)
* Fixed security issue where users were able to use html tags in subscription values. Reported by Andreas Lindh. Fixed with [9d5fb816](https://github.com/andris9/mailtrain/commit/9d5fb816c937114966d4f589e1ad4e164ff3a187)
* Support for multiple HTML editors (Mosaico, GrapeJS, Summernote, HTML code)
## 1.22.0 2017-03-02
* Reverted license back to GPL-v3 to support Mosaico

7
app.js
View file

@ -29,6 +29,7 @@ let templates = require('./routes/templates');
let campaigns = require('./routes/campaigns');
let links = require('./routes/links');
let fields = require('./routes/fields');
let forms = require('./routes/forms');
let segments = require('./routes/segments');
let triggers = require('./routes/triggers');
let webhooks = require('./routes/webhooks');
@ -54,6 +55,7 @@ if (config.www.proxy) {
app.disable('x-powered-by');
hbs.registerPartials(__dirname + '/views/partials');
hbs.registerPartials(__dirname + '/views/subscription/partials/');
/**
* We need this helper to make sure that we consume flash messages only
@ -80,7 +82,9 @@ hbs.registerHelper('flash_messages', function () { // eslint-disable-line prefer
let rows = [];
messages[key].forEach(message => {
rows.push(hbs.handlebars.escapeExpression(message));
message = hbs.handlebars.escapeExpression(message);
message = message.replace(/(\r\n|\n|\r)/gm, '<br>');
rows.push(message);
});
if (rows.length > 1) {
@ -205,6 +209,7 @@ app.use('/campaigns', campaigns);
app.use('/settings', settings);
app.use('/links', links);
app.use('/fields', fields);
app.use('/forms', forms);
app.use('/segments', segments);
app.use('/triggers', triggers);
app.use('/webhooks', webhooks);

View file

@ -38,6 +38,9 @@ language="en"
# Inject custom scripts in layout.hbs
# customscripts=["/custom/hello-world.js"]
# Inject custom scripts in subscription/layout.mjml.hbs
# customsubscriptionscripts=["/custom/hello-world.js"]
# If you start out as a root user (eg. if you want to use ports lower than 1000)
# then you can downgrade the user once all services are up and running
#user="nobody"
@ -112,6 +115,8 @@ host="localhost"
port=3002
baseDN="ou=users,dc=company"
filter="(|(username={{username}})(mail={{username}}))"
#Username field in LDAP (uid/cn/username)
uidTag="username"
passwordresetlink=""
[postfixbounce]

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,27 @@
'use strict';
let config = require('config');
let path = require('path');
let fs = require('fs');
let tools = require('./tools');
let settings = require('./models/settings');
let lists = require('./models/lists');
let fields = require('./models/fields');
let forms = require('./models/forms');
let _ = require('./translate')._;
let objectHash = require('object-hash');
let mjml = require('mjml');
let mjmlTemplates = new Map();
let hbs = require('hbs');
module.exports = {
getDefaultMergeTags,
getListMergeTags
getListMergeTags,
captureFlashMessages,
injectCustomFormData,
injectCustomFormTemplates,
filterCustomFields,
getMjmlTemplate
};
function getDefaultMergeTags(callback) {
@ -73,3 +88,173 @@ function getListMergeTags(listId, callback) {
});
});
}
function filterCustomFields(customFieldsIn = [], fieldIds = [], method = 'include') {
let customFields = customFieldsIn.slice();
fieldIds = typeof fieldIds === 'string' ? fieldIds.split(',') : fieldIds;
customFields.unshift({
id: 'email',
name: 'Email Address',
type: 'Email',
typeSubsciptionEmail: true
}, {
id: 'firstname',
name: 'First Name',
type: 'Text',
typeFirstName: true
}, {
id: 'lastname',
name: 'Last Name',
type: 'Text',
typeLastName: true
});
let filtered = [];
if (method === 'include') {
fieldIds.forEach(id => {
let field = customFields.find(f => f.id.toString() === id);
field && filtered.push(field);
});
} else {
customFields.forEach(field => {
!fieldIds.includes(field.id.toString()) && filtered.push(field);
});
}
return filtered;
}
function injectCustomFormData(customFormId, viewPath, data, callback) {
let injectDefaultData = data => {
data.customFields = filterCustomFields(data.customFields, [], 'exclude');
data.formInputStyle = '@import url(/subscription/form-input-style.css);';
return data;
};
if (Number(customFormId) < 1) {
return callback(null, injectDefaultData(data));
}
forms.get(customFormId, (err, form) => {
if (err) {
return callback(null, injectDefaultData(data));
}
let view = viewPath.split('/')[1];
if (view === 'web-subscribe') {
data.customFields = form.fieldsShownOnSubscribe
? filterCustomFields(data.customFields, form.fieldsShownOnSubscribe)
: filterCustomFields(data.customFields, [], 'exclude');
} else if (view === 'web-manage') {
data.customFields = form.fieldsShownOnManage
? filterCustomFields(data.customFields, form.fieldsShownOnManage)
: filterCustomFields(data.customFields, [], 'exclude');
}
let key = tools.fromDbKey(view);
data.template.template = form[key] || data.template.template;
data.template.layout = form.layout || data.template.layout;
data.formInputStyle = form.formInputStyle || '@import url(/subscription/form-input-style.css);';
settings.list(['ua_code'], (err, configItems) => {
if (err) {
return callback(err);
}
data.uaCode = configItems.uaCode;
data.customSubscriptionScripts = config.customsubscriptionscripts || [];
callback(null, data);
});
});
}
function injectCustomFormTemplates(customFormId, templates, callback) {
if (Number(customFormId) < 1) {
return callback(null, templates);
}
forms.get(customFormId, (err, form) => {
if (err) {
return callback(null, templates);
}
let lookUp = name => {
let key = tools.fromDbKey(
/subscription\/([^.]*)/.exec(name)[1]
);
return form[key] || name;
};
Object.keys(templates).forEach(key => {
let value = templates[key];
if (typeof value === 'string') {
templates[key] = lookUp(value);
}
if (typeof value === 'object' && value.template) {
templates[key].template = lookUp(value.template);
}
if (typeof value === 'object' && value.layout) {
templates[key].layout = lookUp(value.layout);
}
});
callback(null, templates);
});
}
function getMjmlTemplate(template, callback) {
if (!template) {
return callback(null, false);
}
let key = (typeof template === 'object') ? objectHash(template) : template;
if (mjmlTemplates.has(key)) {
return callback(null, mjmlTemplates.get(key));
}
let done = source => {
let compiled;
try {
compiled = mjml.mjml2html(source);
} catch (err) {
return callback(err);
}
if (compiled.errors.length) {
return callback(compiled.errors[0].message || compiled.errors[0]);
}
let renderer = hbs.handlebars.compile(compiled.html);
mjmlTemplates.set(key, renderer);
callback(null, renderer);
};
if (typeof template === 'object') {
tools.mergeTemplateIntoLayout(template.template, template.layout, (err, source) => {
if (err) {
return callback(err);
}
done(source);
});
} else {
fs.readFile(path.join(__dirname, '..', 'views', template), 'utf-8', (err, source) => {
if (err) {
return callback(err);
}
done(source);
});
}
}
function captureFlashMessages(req, res, callback) {
res.render('subscription/capture-flash-messages', { layout: null }, (err, flash) => {
if (err) {
return callback(err);
}
callback(null, flash);
});
}

View file

@ -13,6 +13,8 @@ let path = require('path');
let templates = new Map();
let htmlToText = require('html-to-text');
let aws = require('aws-sdk');
let objectHash = require('object-hash');
let mjml = require('mjml');
let _ = require('./translate')._;
let util = require('util');
@ -124,18 +126,46 @@ function getTemplate(template, callback) {
return callback(null, false);
}
if (templates.has(template)) {
return callback(null, templates.get(template));
let key = (typeof template === 'object') ? objectHash(template) : template;
if (templates.has(key)) {
return callback(null, templates.get(key));
}
fs.readFile(path.join(__dirname, '..', 'views', template), 'utf-8', (err, source) => {
if (err) {
return callback(err);
let done = (source, isMjml = false) => {
if (isMjml) {
let compiled;
try {
compiled = mjml.mjml2html(source);
} catch (err) {
return callback(err);
}
if (compiled.errors.length) {
return callback(compiled.errors[0].message || compiled.errors[0]);
}
source = compiled.html;
}
let renderer = Handlebars.compile(source);
templates.set(template, renderer);
return callback(null, renderer);
});
templates.set(key, renderer);
callback(null, renderer);
};
if (typeof template === 'object') {
tools.mergeTemplateIntoLayout(template.template, template.layout, (err, source) => {
if (err) {
return callback(err);
}
let isMjml = template.type === 'mjml';
done(source, isMjml);
});
} else {
fs.readFile(path.join(__dirname, '..', 'views', template), 'utf-8', (err, source) => {
if (err) {
return callback(err);
}
done(source);
});
}
}
function createMailer(callback) {

View file

@ -619,6 +619,9 @@ module.exports.create = (campaign, opts, callback) => {
Object.keys(campaign).forEach(key => {
let value = typeof campaign[key] === 'number' ? campaign[key] : (campaign[key] || '').toString().trim();
key = tools.toDbKey(key);
if (key === 'description') {
value = tools.purifyHTML(value);
}
if (allowedKeys.indexOf(key) >= 0 && keys.indexOf(key) < 0) {
keys.push(key);
values.push(value);
@ -791,6 +794,9 @@ module.exports.update = (id, updates, callback) => {
Object.keys(campaign).forEach(key => {
let value = typeof campaign[key] === 'number' ? campaign[key] : (campaign[key] || '').toString().trim();
key = tools.toDbKey(key);
if (key === 'description') {
value = tools.purifyHTML(value);
}
if (allowedKeys.indexOf(key) >= 0 && keys.indexOf(key) < 0) {
keys.push(key);
values.push(value);

View file

@ -405,6 +405,7 @@ module.exports.getRow = (fieldList, values, useDate, showAll, onlyExisting) => {
case 'longtext':
{
let item = {
id: field.id,
type: field.type,
name: field.name,
column: field.column,
@ -434,6 +435,7 @@ module.exports.getRow = (fieldList, values, useDate, showAll, onlyExisting) => {
}
let item = {
id: field.id,
type: field.type,
name: field.name,
column: field.column,
@ -449,6 +451,7 @@ module.exports.getRow = (fieldList, values, useDate, showAll, onlyExisting) => {
case 'number':
{
let item = {
id: field.id,
type: field.type,
name: field.name,
column: field.column,
@ -466,6 +469,7 @@ module.exports.getRow = (fieldList, values, useDate, showAll, onlyExisting) => {
case 'checkbox':
{
let item = {
id: field.id,
type: field.type,
name: field.name,
visible: !!field.visible,
@ -556,6 +560,7 @@ module.exports.getRow = (fieldList, values, useDate, showAll, onlyExisting) => {
}
let item = {
id: field.id,
type: field.type,
name: field.name,
column: field.column,

409
lib/models/forms.js Normal file
View file

@ -0,0 +1,409 @@
'use strict';
let db = require('../db');
let fs = require('fs');
let path = require('path');
let tools = require('../tools');
let mjml = require('mjml');
let _ = require('../translate')._;
let allowedKeys = [
'name',
'description',
'fields_shown_on_subscribe',
'fields_shown_on_manage',
'layout',
'form_input_style',
'mail_confirm_html',
'mail_confirm_text',
'mail_subscription_confirmed_html',
'mail_subscription_confirmed_text',
'mail_unsubscribe_confirmed_html',
'mail_unsubscribe_confirmed_text',
'web_confirm_notice',
'web_manage_address',
'web_manage',
'web_subscribe',
'web_subscribed',
'web_unsubscribe_notice',
'web_unsubscribe',
'web_updated_notice'
];
module.exports.list = (listId, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM custom_forms WHERE list=? ORDER BY id', [listId], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let formList = rows && rows.map(row => tools.convertKeys(row)) || [];
return callback(null, formList);
});
});
};
module.exports.get = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Form ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM custom_forms WHERE id=? LIMIT 1', [id], (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
let form = rows && rows[0] && tools.convertKeys(rows[0]) || false;
if (!form) {
connection.release();
return callback(new Error('Selected form not found'));
}
connection.query('SELECT * FROM custom_forms_data WHERE form=?', [id], (err, data_rows = []) => {
connection.release();
if (err) {
return callback(err);
}
data_rows.forEach(data_row => {
let modelKey = tools.fromDbKey(data_row.data_key);
form[modelKey] = data_row.data_value;
});
return callback(null, form);
});
});
});
};
module.exports.create = (listId, form, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing Form ID')));
}
form = tools.convertKeys(form);
form = setDefaultValues(form);
form.name = (form.name || '').toString().trim();
if (!form.name) {
return callback(new Error(_('Form Name must be set')));
}
let keys = ['list'];
let values = [listId];
Object.keys(form).forEach(key => {
let value = form[key].trim();
key = tools.toDbKey(key);
if (key === 'description') {
value = tools.purifyHTML(value);
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);
}
});
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let filtered = filterKeysAndValues(keys, values, 'exclude', ['mail_', 'web_']);
let query = 'INSERT INTO custom_forms (' + filtered.keys.join(', ') + ') VALUES (' + filtered.values.map(() => '?').join(',') + ')';
connection.query(query, filtered.values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let formId = result && result.insertId;
if (!formId) {
return callback(new Error('Invalid custom_forms insertId'));
}
let jobs = 1;
let error = null;
let done = err => {
jobs--;
error = err ? err : error; // One's enough
jobs === 0 && callback(error, formId);
};
filtered = filterKeysAndValues(keys, values, 'include', ['mail_', 'web_']);
filtered.keys.forEach((key, index) => {
jobs++;
db.getConnection((err, connection) => {
if (err) {
return done(err);
}
connection.query('INSERT INTO custom_forms_data (form, data_key, data_value) VALUES (?, ?, ?)', [formId, key, filtered.values[index]], err => {
connection.release();
if (err) {
return done(err);
}
return done(null);
});
});
});
done(null);
});
});
};
module.exports.update = (id, updates, callback) => {
updates = updates || {};
id = Number(id) || 0;
updates = tools.convertKeys(updates);
if (id < 1) {
return callback(new Error(_('Missing Form ID')));
}
if (!(updates.name || '').toString().trim()) {
return callback(new Error(_('Form Name must be set')));
}
let keys = [];
let values = [];
Object.keys(updates).forEach(key => {
let value = typeof updates[key] === 'string' ? updates[key].trim() : updates[key];
key = tools.toDbKey(key);
if (key === 'description') {
value = tools.purifyHTML(value);
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);
}
});
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let filtered = filterKeysAndValues(keys, values, 'exclude', ['mail_', 'web_']);
let query = 'UPDATE custom_forms SET ' + filtered.keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE id=? LIMIT 1';
connection.query(query, filtered.values.concat(id), (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let affectedRows = result && result.affectedRows;
let jobs = 1;
let error = null;
let done = err => {
jobs--;
error = err ? err : error; // One's enough
if (jobs === 0) {
if (error) {
return callback(error);
}
// Save then validate, as otherwise their work get's lost ...
err = testForMjmlErrors(keys, values);
if (err) {
return callback(err);
}
return callback(null, affectedRows);
}
};
filtered = filterKeysAndValues(keys, values, 'include', ['mail_', 'web_']);
filtered.keys.forEach((key, index) => {
jobs++;
db.getConnection((err, connection) => {
if (err) {
return done(err);
}
connection.query('UPDATE custom_forms_data SET data_value=? WHERE data_key=? AND form=?', [filtered.values[index], key, id], err => {
connection.release();
if (err) {
return done(err);
}
return done(null);
});
});
});
done(null);
});
});
};
module.exports.delete = (formId, callback) => {
formId = Number(formId) || 0;
if (formId < 1) {
return callback(new Error(_('Missing Form ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM custom_forms WHERE id=? LIMIT 1', [formId], (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
if (!rows || !rows.length) {
connection.release();
return callback(new Error(_('Custom form not found')));
}
connection.query('DELETE FROM custom_forms WHERE id=? LIMIT 1', [formId], err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
});
};
function setDefaultValues(form) {
let getContents = fileName => {
try {
let basePath = path.join(__dirname, '..', '..');
let template = fs.readFileSync(path.join(basePath, fileName), 'utf8');
return template.replace(/\{\{#translate\}\}(.*?)\{\{\/translate\}\}/g, (m, s) => _(s));
} catch (err) {
return false;
}
};
allowedKeys.forEach(key => {
let modelKey = tools.fromDbKey(key);
let base = 'views/subscription/' + key.replace(/_/g, '-');
if (key.startsWith('mail') || key.startsWith('web')) {
form[modelKey] = getContents(base + '.mjml.hbs') || getContents(base + '.hbs') || '';
}
});
form.layout = getContents('views/subscription/layout.mjml.hbs') || '';
form.formInputStyle = getContents('public/subscription/form-input-style.css') || '@import url(/subscription/form-input-style.css);';
return form;
}
function filterKeysAndValues(keysIn, valuesIn, method = 'include', prefixes = []) {
let values = [];
let prefixMatch = key => (
prefixes.some(prefix => key.startsWith(prefix))
);
let keys = keysIn.filter((key, index) => {
if ((method === 'include' && prefixMatch(key)) || (method === 'exclude' && !prefixMatch(key))) {
values.push(valuesIn[index]);
return true;
}
return false;
});
return {
keys,
values
};
}
function testForMjmlErrors(keys, values) {
let errors = [];
let testLayout = '<mjml><mj-body><mj-container>{{{body}}}</mj-container></mj-body></mjml>';
let hasMjmlError = (template, layout = testLayout) => {
let source = layout.replace(/\{\{\{body\}\}\}/g, template);
let compiled;
try {
compiled = mjml.mjml2html(source);
} catch (err) {
return err;
}
if (compiled.errors.length) {
return compiled.errors[0].message || compiled.errors[0];
}
return null;
};
keys.forEach((key, index) => {
if (key.startsWith('mail_') || key.startsWith('web_')) {
let template = values[index];
let err = hasMjmlError(template);
err && errors.push(key + ': ' + (err.message || err));
key === 'mail_confirm_html' && !template.includes('{{confirmUrl}}') && errors.push(key + ': Missing {{confirmUrl}}');
} else if (key === 'layout') {
let layout = values[index];
let err = hasMjmlError('', layout);
err && errors.push('layout: ' + (err.message || err));
!layout.includes('{{{body}}}') && errors.push('layout: {{{body}}} not found');
}
});
if (errors.length) {
errors.forEach((err, index) => {
errors[index] = (index + 1) + ') ' + err;
});
return 'Please fix these MJML errors:\n\n' + errors.join('\n');
}
return null;
}

View file

@ -6,7 +6,7 @@ let shortid = require('shortid');
let segments = require('./segments');
let _ = require('../translate')._;
let allowedKeys = ['description'];
let allowedKeys = ['description', 'default_form'];
module.exports.list = (start, limit, callback) => {
db.getConnection((err, connection) => {
@ -123,6 +123,9 @@ module.exports.create = (list, callback) => {
Object.keys(list).forEach(key => {
let value = list[key].trim();
key = tools.toDbKey(key);
if (key === 'description') {
value = tools.purifyHTML(value);
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);
@ -182,6 +185,9 @@ module.exports.update = (id, updates, callback) => {
Object.keys(updates).forEach(key => {
let value = updates[key].trim();
key = tools.toDbKey(key);
if (key === 'description') {
value = tools.purifyHTML(value);
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);

View file

@ -3,6 +3,7 @@
let db = require('../db');
let shortid = require('shortid');
let tools = require('../tools');
let helpers = require('../helpers');
let fields = require('./fields');
let geoip = require('geoip-ultralight');
let segments = require('./segments');
@ -210,7 +211,7 @@ module.exports.addConfirmation = (list, email, optInIp, data, callback) => {
}
});
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'serviceUrl'], (err, configItems) => {
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl'], (err, configItems) => {
if (err) {
return callback(err);
}
@ -221,35 +222,55 @@ module.exports.addConfirmation = (list, email, optInIp, data, callback) => {
return;
}
mailer.sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: [].concat(data.firstName || []).concat(data.lastName || []).join(' '),
address: email
},
subject: util.format(_('%s: Please Confirm Subscription'), list.name),
encryptionKeys
}, {
html: 'emails/confirm-html.hbs',
text: 'emails/confirm-text.hbs',
data: {
title: list.name,
contactAddress: configItems.defaultAddress,
confirmUrl: urllib.resolve(configItems.serviceUrl, '/subscription/subscribe/' + cid)
}
}, err => {
let sendMail = (html, text) => {
mailer.sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: [].concat(data.firstName || []).concat(data.lastName || []).join(' '),
address: email
},
subject: util.format(_('%s: Please Confirm Subscription'), list.name),
encryptionKeys
}, {
html,
text,
data: {
title: list.name,
contactAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
confirmUrl: urllib.resolve(configItems.serviceUrl, '/subscription/subscribe/' + cid)
}
}, err => {
if (err) {
log.error('Subscription', err.stack);
}
});
};
let text = {
template: 'subscription/mail-confirm-text.hbs'
};
let html = {
template: 'subscription/mail-confirm-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',
type: 'mjml'
};
helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => {
if (err) {
log.error('Subscription', err.stack);
return sendMail(html, text);
}
sendMail(tmpl.html, tmpl.text);
});
});
return callback(null, cid);
});
});
});
});
});

View file

@ -88,6 +88,9 @@ module.exports.create = (template, callback) => {
Object.keys(template).forEach(key => {
let value = template[key].trim();
key = tools.toDbKey(key);
if (key === 'description') {
value = tools.purifyHTML(value);
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);
@ -133,6 +136,9 @@ module.exports.update = (id, updates, callback) => {
Object.keys(updates).forEach(key => {
let value = updates[key].trim();
key = tools.toDbKey(key);
if (key === 'description') {
value = tools.purifyHTML(value);
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);

View file

@ -16,7 +16,9 @@ let LdapStrategy;
try {
LdapStrategy = require('passport-ldapjs').Strategy; // eslint-disable-line global-require
} catch (E) {
// ignore
if (config.ldap.enabled) {
log.info('LDAP', 'Module "passport-ldapjs" not installed. LDAP auth will fail.');
}
}
module.exports.csrfProtection = csrf({
@ -80,27 +82,28 @@ if (config.ldap.enabled && LdapStrategy) {
base: config.ldap.baseDN,
search: {
filter: config.ldap.filter,
attributes: ['username', 'mail'],
attributes: [config.ldap.uidTag, 'mail'],
scope: 'sub'
}
},
uidTag: config.ldap.uidTag
};
passport.use(new LdapStrategy(opts, (profile, done) => {
users.findByUsername(profile.username, (err, user) => {
users.findByUsername(profile[config.ldap.uidTag], (err, user) => {
if (err) {
return done(err);
}
if (!user) {
// password is empty for ldap
users.add(profile.username, '', profile.mail, (err, id) => {
users.add(profile[config.ldap.uidTag], '', profile.mail, (err, id) => {
if (err) {
return done(err);
}
return done(null, {
id,
username: profile.username
username: profile[config.ldap.uidTag]
});
});
} else {

View file

@ -1,13 +1,17 @@
'use strict';
let fs = require('fs');
let path = require('path');
let db = require('./db');
let slugify = require('slugify');
let Isemail = require('isemail');
let urllib = require('url');
let juice = require('juice');
let jsdom = require('jsdom');
let he = require('he');
let _ = require('./translate')._;
let util = require('util');
let createDOMPurify = require('dompurify');
let blockedUsers = ['abuse', 'admin', 'billing', 'compliance', 'devnull', 'dns', 'ftp', 'hostmaster', 'inoc', 'ispfeedback', 'ispsupport', 'listrequest', 'list', 'maildaemon', 'noc', 'noreply', 'noreply', 'null', 'phish', 'phishing', 'postmaster', 'privacy', 'registrar', 'root', 'security', 'spam', 'support', 'sysadmin', 'tech', 'undisclosedrecipients', 'unsubscribe', 'usenet', 'uucp', 'webmaster', 'www'];
@ -22,6 +26,8 @@ module.exports = {
formatMessage,
getMessageLinks,
prepareHtml,
purifyHTML,
mergeTemplateIntoLayout,
workers: new Set()
};
@ -169,7 +175,7 @@ function getMessageLinks(serviceUrl, campaign, list, subscription) {
};
}
function formatMessage(serviceUrl, campaign, list, subscription, message, filter) {
function formatMessage(serviceUrl, campaign, list, subscription, message, filter, isHTML) {
filter = typeof filter === 'function' ? filter : (str => str);
let links = getMessageLinks(serviceUrl, campaign, list, subscription);
@ -180,7 +186,9 @@ function formatMessage(serviceUrl, campaign, list, subscription, message, filter
return links[key];
}
if (subscription.mergeTags.hasOwnProperty(key)) {
return subscription.mergeTags[key];
return isHTML ? he.encode(subscription.mergeTags[key], {
useNamedReferences: true
}) : subscription.mergeTags[key];
}
return false;
};
@ -196,8 +204,13 @@ function prepareHtml(html, callback) {
if (!(html || '').toString().trim()) {
return callback(null, false);
}
jsdom.env(html, (err, win) => {
jsdom.env(false, false, {
html,
features: {
FetchExternalResources: false, // disables resource loading over HTTP / filesystem
ProcessExternalResources: false // do not execute JS within script blocks
}
}, (err, win) => {
if (err) {
return callback(err);
}
@ -224,3 +237,62 @@ function prepareHtml(html, callback) {
return callback(null, juice(preparedHtml));
});
}
function purifyHTML(html) {
let win = jsdom.jsdom('', {
features: {
FetchExternalResources: false, // disables resource loading over HTTP / filesystem
ProcessExternalResources: false // do not execute JS within script blocks
}
}).defaultView;
let DOMPurify = createDOMPurify(win);
return DOMPurify.sanitize(html);
}
// TODO Simplify!
function mergeTemplateIntoLayout(template, layout, callback) {
layout = layout || '{{{body}}}';
let readFile = (relPath, callback) => {
fs.readFile(path.join(__dirname, '..', 'views', relPath), 'utf-8', (err, source) => {
if (err) {
return callback(err);
}
callback(null, source);
});
};
let done = (template, layout) => {
let source = layout.replace(/\{\{\{body\}\}\}/g, template);
return callback(null, source);
};
if (layout.endsWith('.hbs')) {
readFile(layout, (err, layout) => {
if (err) {
return callback(err);
}
// Please dont end your custom messages with .hbs ...
if (template.endsWith('.hbs')) {
readFile(template, (err, template) => {
if (err) {
return callback(err);
}
return done(template, layout);
});
} else {
return done(template, layout);
}
});
} else if (template.endsWith('.hbs')) {
readFile(template, (err, template) => {
if (err) {
return callback(err);
}
return done(template, layout);
});
} else {
return done(template, layout);
}
}

View file

@ -1,3 +1,3 @@
{
"schemaVersion": 21
"schemaVersion": 22
}

View file

@ -1,7 +1,7 @@
{
"name": "mailtrain",
"private": true,
"version": "1.22.0",
"version": "1.23.0",
"description": "Self hosted email newsletter app",
"main": "index.js",
"scripts": {
@ -47,6 +47,7 @@
"csurf": "^1.9.0",
"csv-generate": "^1.0.0",
"csv-parse": "^1.2.0",
"dompurify": "^0.8.5",
"escape-html": "^1.0.3",
"express": "^4.15.2",
"express-session": "^1.15.1",
@ -69,6 +70,7 @@
"libmime": "^3.1.0",
"marked": "^0.3.6",
"memory-cache": "^0.1.6",
"mjml": "^3.3.0",
"mkdirp": "^0.5.1",
"moment-timezone": "^0.5.11",
"morgan": "^1.8.1",
@ -80,6 +82,7 @@
"nodemailer": "^3.1.8",
"nodemailer-openpgp": "^1.0.2",
"npmlog": "^4.0.2",
"object-hash": "^1.1.7",
"openpgp": "^2.5.1",
"passport": "^0.3.2",
"passport-local": "^1.0.0",

1
public/ace/mode-css.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
ace.define("ace/mode/plain_text",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/text_highlight_rules","ace/mode/behaviour"],function(e,t,n){"use strict";var r=e("../lib/oop"),i=e("./text").Mode,s=e("./text_highlight_rules").TextHighlightRules,o=e("./behaviour").Behaviour,u=function(){this.HighlightRules=s,this.$behaviour=new o};r.inherits(u,i),function(){this.type="text",this.getNextLineIndent=function(e,t,n){return""},this.$id="ace/mode/plain_text"}.call(u.prototype),t.Mode=u})

1
public/ace/worker-css.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,6 +0,0 @@
/* eslint-env browser */
'use strict';
document.getElementById('unsubscribe-button').click();
document.getElementById('unsubscribe-form').submit();

View file

@ -0,0 +1,156 @@
/*!
* JavaScript Cookie v2.1.3
* https://github.com/js-cookie/js-cookie
*
* Copyright 2006, 2015 Klaus Hartl & Fagner Brack
* Released under the MIT license
*/
;(function (factory) {
var registeredInModuleLoader = false;
if (typeof define === 'function' && define.amd) {
define(factory);
registeredInModuleLoader = true;
}
if (typeof exports === 'object') {
module.exports = factory();
registeredInModuleLoader = true;
}
if (!registeredInModuleLoader) {
var OldCookies = window.Cookies;
var api = window.Cookies = factory();
api.noConflict = function () {
window.Cookies = OldCookies;
return api;
};
}
}(function () {
function extend () {
var i = 0;
var result = {};
for (; i < arguments.length; i++) {
var attributes = arguments[ i ];
for (var key in attributes) {
result[key] = attributes[key];
}
}
return result;
}
function init (converter) {
function api (key, value, attributes) {
var result;
if (typeof document === 'undefined') {
return;
}
// Write
if (arguments.length > 1) {
attributes = extend({
path: '/'
}, api.defaults, attributes);
if (typeof attributes.expires === 'number') {
var expires = new Date();
expires.setMilliseconds(expires.getMilliseconds() + attributes.expires * 864e+5);
attributes.expires = expires;
}
try {
result = JSON.stringify(value);
if (/^[\{\[]/.test(result)) {
value = result;
}
} catch (e) {}
if (!converter.write) {
value = encodeURIComponent(String(value))
.replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g, decodeURIComponent);
} else {
value = converter.write(value, key);
}
key = encodeURIComponent(String(key));
key = key.replace(/%(23|24|26|2B|5E|60|7C)/g, decodeURIComponent);
key = key.replace(/[\(\)]/g, escape);
return (document.cookie = [
key, '=', value,
attributes.expires ? '; expires=' + attributes.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE
attributes.path ? '; path=' + attributes.path : '',
attributes.domain ? '; domain=' + attributes.domain : '',
attributes.secure ? '; secure' : ''
].join(''));
}
// Read
if (!key) {
result = {};
}
// To prevent the for loop in the first place assign an empty array
// in case there are no cookies at all. Also prevents odd result when
// calling "get()"
var cookies = document.cookie ? document.cookie.split('; ') : [];
var rdecode = /(%[0-9A-Z]{2})+/g;
var i = 0;
for (; i < cookies.length; i++) {
var parts = cookies[i].split('=');
var cookie = parts.slice(1).join('=');
if (cookie.charAt(0) === '"') {
cookie = cookie.slice(1, -1);
}
try {
var name = parts[0].replace(rdecode, decodeURIComponent);
cookie = converter.read ?
converter.read(cookie, name) : converter(cookie, name) ||
cookie.replace(rdecode, decodeURIComponent);
if (this.json) {
try {
cookie = JSON.parse(cookie);
} catch (e) {}
}
if (key === name) {
result = cookie;
break;
}
if (!key) {
result[name] = cookie;
}
} catch (e) {}
}
return result;
}
api.set = api;
api.get = function (key) {
return api.call(api, key);
};
api.getJSON = function () {
return api.apply({
json: true
}, [].slice.call(arguments));
};
api.defaults = {};
api.remove = function (key, attributes) {
api(key, '', extend(attributes, {
expires: -1
}));
};
api.withConverter = init;
return api;
}
return init(function () {});
}));

View file

@ -9,6 +9,9 @@ $('.summernote').summernote({
tabsize: 2
});
// https://ace.c9.io/#nav=higlighter
// https://github.com/ajaxorg/ace-builds/tree/v1.2.3/src-min-noconflict
$('div.code-editor').each(function () {
var editor = ace.edit(this);
var textarea = document.querySelector('input[name=html]');
@ -17,8 +20,34 @@ $('div.code-editor').each(function () {
editor.getSession().setMode('ace/mode/html');
editor.getSession().setUseWrapMode(true);
editor.getSession().setUseSoftTabs(true);
editor.setShowPrintMargin(false);
editor.getSession().on('change', function () {
textarea.value = editor.getSession().getValue();
});
textarea.value = editor.getSession().getValue();
});
$('div[class*="code-editor-"]').each(function () {
var input = $(this).siblings('input')[0];
var mode = 'html';
var editor = ace.edit(this);
if ($(this).hasClass('code-editor-text')) {
mode = 'plain_text';
} else if ($(this).hasClass('code-editor-mjml')) {
mode = 'html';
editor.getSession().setUseWorker(false);
} else if ($(this).hasClass('code-editor-css')) {
mode = 'css';
}
editor.setTheme('ace/theme/chrome');
editor.setShowPrintMargin(false);
editor.getSession().setMode('ace/mode/' + mode);
editor.getSession().setUseWrapMode(true);
editor.getSession().setUseSoftTabs(true);
editor.getSession().setValue(input.value);
editor.getSession().on('change', function () {
input.value = editor.getSession().getValue();
});
});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,215 @@
/* --- Colors ----------
Input Border: #DCE4EC
Input Group: #FAFAFA
Muted: #999999
Anchor: #1F68D5
Alerts: ...
*/
/* --- General -------- */
form {
margin: .5em 0 1em;
}
.form-group {
margin-bottom: 1.2em;
box-sizing: border-box;
}
input,
select,
textarea {
margin-bottom: 0;
}
label {
display: block;
font-size: 1em;
font-weight: 700;
margin-bottom: .3em;
}
.label-checkbox,
.label-radio {
font-weight: normal;
font-size: .9em;
}
.label-inline {
display: inline-block;
font-weight: normal;
margin-left: .3em;
}
/* --- Inputs ------------- */
input[type='email'],
input[type='number'],
input[type='password'],
input[type='search'],
input[type='tel'],
input[type='text'],
input[type='url'],
textarea,
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-color: transparent;
border: 2px solid #DCE4EC;
border-radius: 4px;
box-shadow: none;
box-sizing: border-box;
height: 2.8em;
padding: .3em .7em;
width: 100%;
font-size: 1.2em;
font-style: inherit;
}
input[type='email']:focus,
input[type='number']:focus,
input[type='password']:focus,
input[type='search']:focus,
input[type='tel']:focus,
input[type='text']:focus,
input[type='url']:focus,
textarea:focus,
select:focus {
border-color: #2D3E4F;
outline: 0;
}
input[readonly] {
color: #999999;
}
input[readonly]:focus {
border-color: #DCE4EC;
}
input[type="checkbox"],
input[type="radio"] {
margin-bottom: 0;
margin-right: .2em;
}
input[type='checkbox'],
input[type='radio'] {
display: inline;
}
select {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat;
padding-right: 2em;
}
select:focus {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#2D3E4F" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>');
}
textarea {
min-height: 8em;
max-width: 100%;
}
/* --- Input Group --------- */
.input-group {
position: relative;
}
.input-group-addon {
position: absolute;
right: 0;
top: 0;
background: #FAFAFA;
height: 100%;
padding: 0 .75em;
border: 2px solid #DCE4EC;
box-sizing: border-box;
border-bottom-right-radius: 4px;
border-top-right-radius: 4px;
}
.input-group-addon > * {
line-height: 2.8em;
}
/* --- Alerts ------------- */
.alert {
margin: 1.25em auto 0;
padding: 1em;
border-radius: 4px;
font-family: inherit;
font-size: 1em;
box-sizing: border-box;
}
.alert-dismissible .close {
display: none;
}
.alert-success { color: #397740; background-color: #DEF0D9; border-color: #CFEAC8; }
.alert-info { color: #33708E; background-color: #D9EDF6; border-color: #BCDFF0; }
.alert-warning { color: #8A6D3F; background-color: #FCF8E4; border-color: #F9F2CE; }
.alert-danger { color: #AA4144; background-color: #F2DEDE; border-color: #EBCCCC; }
/* --- GPG Key ------------- */
.form-group.gpg > label {
display: inline-block;
}
.btn-download-pubkey,
.btn-download-pubkey:focus,
.btn-download-pubkey:active {
background: none;
border: none;
display: block;
font: inherit;
font-size: .8em;
margin: .3em 0 0;
padding: 0;
outline: none;
outline-offset: 0;
color: #1F68D5;
cursor: pointer;
text-transform: none;
height: auto;
float: right;
}
.btn-download-pubkey:hover {
text-decoration: underline;
}
.gpg-text {
font-family: monospace;
font-size: .8em;
}
/* --- Other ------------- */
.help-block {
display: block;
font-size: .9em;
line-height: 1;
color: #999999;
}
form a {
color: #1F68D5;
text-decoration: none;
}
form a:hover {
text-decoration: underline;
}

View file

@ -67,7 +67,7 @@ router.get('/:campaign/:list/:subscription', passport.csrfProtection, (req, res,
let render = (view, layout) => {
res.render(view, {
layout,
message: renderTags ? tools.formatMessage(serviceUrl, campaign, list, subscription, html) : html,
message: renderTags ? tools.formatMessage(serviceUrl, campaign, list, subscription, html, false, true) : html,
campaign,
list,
subscription,
@ -80,6 +80,9 @@ router.get('/:campaign/:list/:subscription', passport.csrfProtection, (req, res,
res.render('partials/tracking-scripts', {
layout: 'archive/layout-raw'
}, (err, scripts) => {
if (err) {
return next(err);
}
html = scripts ? html.replace(/<\/body\b/i, match => scripts + match) : html;
render('archive/view-raw', 'archive/layout-raw');
});

296
routes/forms.js Normal file
View file

@ -0,0 +1,296 @@
'use strict';
let express = require('express');
let router = new express.Router();
let passport = require('../lib/passport');
let tools = require('../lib/tools');
let helpers = require('../lib/helpers');
let _ = require('../lib/translate')._;
let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields');
let forms = require('../lib/models/forms');
let subscriptions = require('../lib/models/subscriptions');
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('lists');
next();
});
router.get('/:list', passport.csrfProtection, (req, res) => {
lists.get(req.params.list, (err, list) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
if (!list) {
req.flash('danger', _('Selected list ID not found'));
return res.redirect('/');
}
forms.list(list.id, (err, rows) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/forms/' + encodeURIComponent(req.params.list));
}
let index = 0;
res.render('lists/forms/forms', {
customForms: rows.map(row => {
row.index = ++index;
row.isDefaultForm = list.defaultForm === row.id;
return row;
}),
list,
csrfToken: req.csrfToken()
});
});
});
});
router.get('/:list/create', passport.csrfProtection, (req, res) => {
lists.get(req.params.list, (err, list) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
if (!list) {
req.flash('danger', _('Selected list ID not found'));
return res.redirect('/');
}
let data = {};
data.csrfToken = req.csrfToken();
data.list = list;
res.render('lists/forms/create', data);
});
});
router.post('/:list/create', passport.parseForm, passport.csrfProtection, (req, res) => {
forms.create(req.params.list, req.body, (err, id) => {
if (err || !id) {
req.flash('danger', err && err.message || err || _('Could not create custom form'));
return res.redirect('/forms/' + encodeURIComponent(req.params.list) + '/create?' + tools.queryParams(req.body));
}
req.flash('success', 'Custom form created');
res.redirect('/forms/' + encodeURIComponent(req.params.list) + '/edit/' + id);
});
});
router.get('/:list/edit/:form', passport.csrfProtection, (req, res) => {
lists.get(req.params.list, (err, list) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
if (!list) {
req.flash('danger', _('Selected list ID not found'));
return res.redirect('/');
}
forms.get(req.params.form, (err, form) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/forms/' + encodeURIComponent(req.params.list));
}
if (!form) {
req.flash('danger', _('Selected form not found'));
return res.redirect('/forms/' + encodeURIComponent(req.params.list));
}
fields.list(list.id, (err, rows) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/forms/' + encodeURIComponent(req.params.list));
}
let customFields = rows.map(row => {
row.type = fields.types[row.type];
return row;
});
let allFields = helpers.filterCustomFields(customFields, [], 'exclude');
let fieldsShownOnSubscribe = allFields;
let fieldsHiddenOnSubscribe = [];
let fieldsShownOnManage = allFields;
let fieldsHiddenOnManage = [];
if (form.fieldsShownOnSubscribe) {
fieldsShownOnSubscribe = helpers.filterCustomFields(customFields, form.fieldsShownOnSubscribe, 'include');
fieldsHiddenOnSubscribe = helpers.filterCustomFields(customFields, form.fieldsShownOnSubscribe, 'exclude');
}
if (form.fieldsShownOnManage) {
fieldsShownOnManage = helpers.filterCustomFields(customFields, form.fieldsShownOnManage, 'include');
fieldsHiddenOnManage = helpers.filterCustomFields(customFields, form.fieldsShownOnManage, 'exclude');
}
let helpEmailText = _('The plaintext version for this email');
let helpMjmlBase = _('Custom forms use MJML for formatting');
let helpMjmlDocLink = _('See the MJML documentation <a class="mjml-documentation">here</a>');
let helpMjmlGeneral = helpMjmlBase + ' ' + helpMjmlDocLink;
let templateOptgroups = [
{
label: _('General'),
opts: [{
name: 'layout',
label: _('Layout'),
type: 'mjml',
help: helpMjmlGeneral,
isLayout: true
}, {
name: 'form_input_style',
label: _('Form Input Style'),
type: 'css',
help: _('This CSS stylesheet defines the appearance of form input elements and alerts')
}]
}, {
label: _('Subscribe'),
opts: [{
name: 'web_subscribe',
label: _('Web - Subscribe'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'web_confirm_notice',
label: _('Web - Confirm Notice'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_confirm_html',
label: _('Mail - Confirm Subscription (MJML)'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_confirm_text',
label: _('Mail - Confirm Subscription (Text)'),
type: 'text',
help: helpEmailText
}, {
name: 'web_subscribed',
label: _('Web - Subscribed Notice'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_subscription_confirmed_html',
label: _('Mail - Subscription Confirmed (MJML)'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_subscription_confirmed_text',
label: _('Mail - Subscription Confirmed (Text)'),
type: 'text',
help: helpEmailText
}]
}, {
label: _('Manage'),
opts: [{
name: 'web_manage',
label: _('Web - Manage Preferences'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'web_manage_address',
label: _('Web - Manage Address'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'web_updated_notice',
label: _('Web - Updated Notice'),
type: 'mjml',
help: helpMjmlGeneral
}]
}, {
label: _('Unsubscribe'),
opts: [{
name: 'web_unsubscribe',
label: _('Web - Unsubscribe'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'web_unsubscribe_notice',
label: _('Web - Unsubscribe Notice'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_unsubscribe_confirmed_html',
label: _('Mail - Unsubscribe Confirmed (MJML)'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_unsubscribe_confirmed_text',
label: _('Mail - Unsubscribe Confirmed (Text)'),
type: 'text',
help: helpEmailText
}]
}
];
templateOptgroups.forEach(group => {
group.opts.forEach(opt => {
let key = tools.fromDbKey(opt.name);
opt.value = form[key];
});
});
subscriptions.listTestUsers(list.id, (err, testUsers) => {
res.render('lists/forms/edit', {
csrfToken: req.csrfToken(),
list,
form,
templateOptgroups,
fieldsShownOnSubscribe,
fieldsHiddenOnSubscribe,
fieldsShownOnManage,
fieldsHiddenOnManage,
testUsers,
useEditor: true
});
});
});
});
});
});
router.post('/:list/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
forms.update(req.body.id, req.body, (err, updated) => {
if (err) {
req.flash('danger', err.message || err);
} else if (updated) {
req.flash('success', _('Form settings updated'));
} else {
req.flash('info', _('Form settings not updated'));
}
if (req.body.id) {
return res.redirect('/forms/' + encodeURIComponent(req.params.list) + '/edit/' + encodeURIComponent(req.body.id));
} else {
return res.redirect('/forms/' + encodeURIComponent(req.params.list));
}
});
});
router.post('/:list/delete', passport.parseForm, passport.csrfProtection, (req, res) => {
forms.delete(req.body.id, (err, deleted) => {
if (err) {
req.flash('danger', err && err.message || err);
} else if (deleted) {
req.flash('success', _('Custom form deleted'));
} else {
req.flash('info', _('Could not delete specified form'));
}
return res.redirect('/forms/' + encodeURIComponent(req.params.list));
});
});
module.exports = router;

View file

@ -8,6 +8,7 @@ let router = new express.Router();
let lists = require('../lib/models/lists');
let subscriptions = require('../lib/models/subscriptions');
let fields = require('../lib/models/fields');
let forms = require('../lib/models/forms');
let tools = require('../lib/tools');
let striptags = require('striptags');
let htmlescape = require('escape-html');
@ -101,8 +102,21 @@ router.get('/edit/:id', passport.csrfProtection, (req, res) => {
req.flash('danger', err && err.message || err || _('Could not find list with specified ID'));
return res.redirect('/lists');
}
list.csrfToken = req.csrfToken();
res.render('lists/edit', list);
forms.list(list.id, (err, customForms) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/lists');
}
list.customForms = customForms.map(row => {
row.selected = list.defaultForm === row.id;
return row;
});
list.csrfToken = req.csrfToken();
res.render('lists/edit', list);
});
});
});
@ -117,7 +131,9 @@ router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) =>
req.flash('info', _('List settings not updated'));
}
if (req.body.id) {
if (req.query.next) {
return res.redirect(req.query.next);
} else if (req.body.id) {
return res.redirect('/lists/edit/' + encodeURIComponent(req.body.id));
} else {
return res.redirect('/lists');
@ -219,7 +235,7 @@ router.post('/ajax/:id', (req, res) => {
} else {
return htmlescape(cRow.value || '');
}
})).concat(statuses[row.status]).concat(row.created && row.created.toISOString ? '<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>' : 'N/A').concat('<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span><a href="/lists/subscription/' + list.id + '/edit/' + row.cid + '">' + _('Edit') + '</a>'))
})).concat(statuses[row.status]).concat(row.created && row.created.toISOString ? '<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>' : 'N/A').concat('<a href="/lists/subscription/' + list.id + '/edit/' + row.cid + '">' + _('Edit') + '</a>'))
});
});
});

View file

@ -3,6 +3,7 @@
let log = require('npmlog');
let tools = require('../lib/tools');
let helpers = require('../lib/helpers');
let mailer = require('../lib/mailer');
let passport = require('../lib/passport');
let express = require('express');
@ -10,11 +11,13 @@ let urllib = require('url');
let router = new express.Router();
let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields');
let forms = require('../lib/models/forms');
let subscriptions = require('../lib/models/subscriptions');
let settings = require('../lib/models/settings');
let openpgp = require('openpgp');
let _ = require('../lib/translate')._;
let util = require('util');
let hbs = require('hbs');
router.get('/subscribe/:cid', (req, res, next) => {
subscriptions.subscribe(req.params.cid, req.ip, (err, subscription) => {
@ -37,17 +40,44 @@ router.get('/subscribe/:cid', (req, res, next) => {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl', 'pgpPrivateKey', 'defaultAddress', 'defaultFrom', 'disableConfirmations'], (err, configItems) => {
settings.list(['defaultHomepage', 'serviceUrl', 'pgpPrivateKey', 'defaultAddress', 'defaultPostaddress', 'defaultFrom', 'disableConfirmations'], (err, configItems) => {
if (err) {
return next(err);
}
res.render('subscription/subscribed', {
let data = {
title: list.name,
layout: 'subscription/layout',
homepage: configItems.defaultHomepage || configItems.serviceUrl,
preferences: '/subscription/' + list.cid + '/manage/' + subscription.cid,
hasPubkey: !!configItems.pgpPrivateKey
hasPubkey: !!configItems.pgpPrivateKey,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
template: {
template: 'subscription/web-subscribed.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
}
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribed', data, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
if (configItems.disableConfirmations) {
@ -66,30 +96,52 @@ router.get('/subscribe/:cid', (req, res, next) => {
}
});
mailer.sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '),
address: subscription.email
},
subject: util.format(_('%s: Subscription Confirmed'), list.name),
encryptionKeys
}, {
html: 'emails/subscription-confirmed-html.hbs',
text: 'emails/subscription-confirmed-text.hbs',
data: {
title: list.name,
contactAddress: configItems.defaultAddress,
preferencesUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid),
unsubscribeUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid)
}
}, err => {
let sendMail = (html, text) => {
mailer.sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '),
address: subscription.email
},
subject: util.format(_('%s: Subscription Confirmed'), list.name),
encryptionKeys
}, {
html,
text,
data: {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
contactAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
preferencesUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid),
unsubscribeUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid),
}
}, err => {
if (err) {
log.error('Subscription', err.stack);
}
});
};
let text = {
template: 'subscription/mail-subscription-confirmed-text.hbs'
};
let html = {
template: 'subscription/mail-subscription-confirmed-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',
type: 'mjml'
};
helpers.injectCustomFormTemplates(req.query.fid || list.defaultForm, { text, html }, (err, tmpl) => {
if (err) {
log.error('Subscription', err.stack);
return sendMail(html, text);
}
sendMail(tmpl.html, tmpl.text);
});
});
});
@ -124,12 +176,41 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => {
data.customFields = fields.getRow(fieldList, data);
data.useEditor = true;
settings.list(['pgpPrivateKey'], (err, configItems) => {
settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
data.hasPubkey = !!configItems.pgpPrivateKey;
res.render('subscription/subscribe', data);
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
data.template = {
template: 'subscription/web-subscribe.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
});
@ -146,15 +227,43 @@ router.get('/:cid/confirm-notice', (req, res, next) => {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl'], (err, configItems) => {
settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
res.render('subscription/confirm-notice', {
let data = {
title: list.name,
layout: 'subscription/layout',
homepage: configItems.defaultHomepage || configItems.serviceUrl
homepage: configItems.defaultHomepage || configItems.serviceUrl,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
template: {
template: 'subscription/web-confirm-notice.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
}
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-confirm-notice', data, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.isConfirmNotice = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
@ -171,15 +280,42 @@ router.get('/:cid/updated-notice', (req, res, next) => {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl'], (err, configItems) => {
settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
res.render('subscription/updated-notice', {
let data = {
title: list.name,
layout: 'subscription/layout',
homepage: configItems.defaultHomepage || configItems.serviceUrl
homepage: configItems.defaultHomepage || configItems.serviceUrl,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
template: {
template: 'subscription/web-updated-notice.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
}
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-updated-notice', data, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
@ -196,15 +332,43 @@ router.get('/:cid/unsubscribe-notice', (req, res, next) => {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl'], (err, configItems) => {
settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
res.render('subscription/unsubscribe-notice', {
let data = {
title: list.name,
layout: 'subscription/layout',
homepage: configItems.defaultHomepage || configItems.serviceUrl
homepage: configItems.defaultHomepage || configItems.serviceUrl,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
template: {
template: 'subscription/web-unsubscribe-notice.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
}
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-unsubscribe-notice', data, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
@ -298,13 +462,42 @@ router.get('/:lcid/manage/:ucid', passport.csrfProtection, (req, res, next) => {
subscription.useEditor = true;
settings.list(['pgpPrivateKey'], (err, configItems) => {
settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
subscription.hasPubkey = !!configItems.pgpPrivateKey;
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
res.render('subscription/manage', subscription);
subscription.template = {
template: 'subscription/web-manage.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-manage', subscription, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.isManagePreferences = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
});
@ -344,24 +537,55 @@ router.get('/:lcid/manage-address/:ucid', passport.csrfProtection, (req, res, ne
return next(err);
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && !subscription) {
err = new Error(_('Subscription not found from this list'));
err.status = 404;
settings.list(['defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.layout = 'subscription/layout';
subscription.useEditor = true;
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && !subscription) {
err = new Error(_('Subscription not found from this list'));
err.status = 404;
}
res.render('subscription/manage-address', subscription);
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
template: 'subscription/web-manage-address.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-manage-address', subscription, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.needsJsWarning = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
});
});
router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection, (req, res, next) => {
lists.getByCid(req.params.lcid, (err, list) => {
if (!err && !list) {
@ -397,23 +621,56 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next)
return next(err);
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && !subscription) {
err = new Error(_('Subscription not found from this list'));
err.status = 404;
}
settings.list(['defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.layout = 'subscription/layout';
subscription.autosubmit = !!req.query.auto;
subscription.campaign = req.query.c;
res.render('subscription/unsubscribe', subscription);
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && !subscription) {
err = new Error(_('Subscription not found from this list'));
err.status = 404;
}
if (err) {
return next(err);
}
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.autosubmit = !!req.query.auto;
subscription.campaign = req.query.c;
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
template: 'subscription/web-unsubscribe.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-unsubscribe', subscription, (err, data) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
if (err) {
return next(err);
}
data.isWeb = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
});
});
});
});
@ -451,7 +708,7 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (
}
});
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'serviceUrl', 'disableConfirmations'], (err, configItems) => {
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', 'disableConfirmations'], (err, configItems) => {
if (err) {
return log.error('Settings', err);
}
@ -460,29 +717,50 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (
return;
}
mailer.sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '),
address: subscription.email
},
subject: util.format(_('%s: Subscription Confirmed'), list.name),
encryptionKeys
}, {
html: 'emails/unsubscribe-confirmed-html.hbs',
text: 'emails/unsubscribe-confirmed-text.hbs',
data: {
title: list.name,
contactAddress: configItems.defaultAddress,
subscribeUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '?cid=' + subscription.cid)
}
}, err => {
let sendMail = (html, text) => {
mailer.sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '),
address: subscription.email
},
subject: util.format(_('%s: Unsubscribe Confirmed'), list.name),
encryptionKeys
}, {
html,
text,
data: {
title: list.name,
contactAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
subscribeUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '?cid=' + subscription.cid),
}
}, err => {
if (err) {
log.error('Subscription', err.stack);
}
});
};
let text = {
template: 'subscription/mail-unsubscribe-confirmed-text.hbs'
};
let html = {
template: 'subscription/mail-unsubscribe-confirmed-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',
type: 'mjml'
};
helpers.injectCustomFormTemplates(req.query.fid || list.defaultForm, { text, html }, (err, tmpl) => {
if (err) {
log.error('Subscription', err.stack);
return sendMail(html, text);
}
sendMail(tmpl.html, tmpl.text);
});
});
});

View file

@ -371,7 +371,7 @@ function formatMessage(message, callback) {
let campaignAddress = [campaign.cid, list.cid, message.subscription.cid].join('.');
let renderedHtml = renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html) : html;
let renderedHtml = renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html, false, true) : html;
let renderedText = (text || '').trim() ? (renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, text) : text) : htmlToText.fromString(renderedHtml, {
wordwrap: 130

View file

@ -113,6 +113,29 @@ CREATE TABLE `custom_fields` (
KEY `list_2` (`list`),
CONSTRAINT `custom_fields_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `custom_forms` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`list` int(11) unsigned NOT NULL,
`name` varchar(255) DEFAULT '',
`description` text,
`fields_shown_on_subscribe` varchar(255) DEFAULT '',
`fields_shown_on_manage` varchar(255) DEFAULT '',
`layout` longtext,
`form_input_style` longtext,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `list` (`list`),
CONSTRAINT `custom_forms_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `custom_forms_data` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`form` int(11) unsigned NOT NULL,
`data_key` varchar(255) DEFAULT '',
`data_value` longtext,
PRIMARY KEY (`id`),
KEY `form` (`form`),
CONSTRAINT `custom_forms_data_ibfk_1` FOREIGN KEY (`form`) REFERENCES `custom_forms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `import_failed` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`import` int(11) unsigned NOT NULL,
@ -157,6 +180,7 @@ CREATE TABLE `links` (
CREATE TABLE `lists` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` varchar(255) CHARACTER SET ascii NOT NULL,
`default_form` int(11) unsigned DEFAULT NULL,
`name` varchar(255) NOT NULL DEFAULT '',
`description` text,
`subscribers` int(11) unsigned DEFAULT '0',
@ -212,7 +236,7 @@ CREATE TABLE `settings` (
`value` text NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `key` (`key`)
) ENGINE=InnoDB AUTO_INCREMENT=38 DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB AUTO_INCREMENT=39 DEFAULT CHARSET=utf8mb4;
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (1,'smtp_hostname','smtp-pulse.com');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (2,'smtp_port','465');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (3,'smtp_encryption','TLS');
@ -229,7 +253,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','21');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','22');
CREATE TABLE `subscription` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` varchar(255) CHARACTER SET ascii NOT NULL,

View file

@ -0,0 +1,38 @@
# Header section
# Define incrementing schema version number
SET @schema_version = '22';
# Create table to store custom forms
CREATE TABLE `custom_forms` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`list` int(11) unsigned NOT NULL,
`name` varchar(255) DEFAULT '',
`description` text,
`fields_shown_on_subscribe` varchar(255) DEFAULT '',
`fields_shown_on_manage` varchar(255) DEFAULT '',
`layout` longtext,
`form_input_style` longtext,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `list` (`list`),
CONSTRAINT `custom_forms_ibfk_1` FOREIGN KEY (`list`) REFERENCES `lists` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
# Create table to store custom form data
CREATE TABLE `custom_forms_data` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`form` int(11) unsigned NOT NULL,
`data_key` varchar(255) DEFAULT '',
`data_value` longtext,
PRIMARY KEY (`id`),
KEY `form` (`form`),
CONSTRAINT `custom_forms_data_ibfk_1` FOREIGN KEY (`form`) REFERENCES `custom_forms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
# Add default_form to lists
ALTER TABLE `lists` ADD COLUMN `default_form` int(11) unsigned DEFAULT NULL AFTER `cid`;
# 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;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -17,12 +17,14 @@
<form class="form-horizontal" method="post" action="/lists/edit">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="id" value="{{id}}" />
<div class="form-group">
<label for="name" class="col-sm-2 control-label">{{#translate}}Name{{/translate}}</label>
<div class="col-sm-10">
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="{{#translate}}List Name{{/translate}}" autofocus required>
</div>
</div>
<div class="form-group">
<label for="name" class="col-sm-2 control-label">{{#translate}}List ID{{/translate}}</label>
<div class="col-sm-10">
@ -30,6 +32,7 @@
<span class="help-block">{{#translate}}This is the list ID displayed to the subscribers{{/translate}}</span>
</div>
</div>
<div class="form-group">
<label for="description" class="col-sm-2 control-label">{{#translate}}Description{{/translate}}</label>
<div class="col-sm-10">
@ -38,6 +41,19 @@
</div>
</div>
<div class="form-group">
<label for="default_form" class="col-sm-2 control-label">{{#translate}}Custom Form{{/translate}}</label>
<div class="col-sm-10">
<select class="form-control" id="default_form" name="default_form">
<option value="0">{{#translate}}Default Mailtrain Form{{/translate}}</option>
{{#each customForms}}
<option value="{{id}}" {{#if selected}}selected{{/if}}>{{name}}</option>
{{/each}}
</select>
<span class="help-block">{{#translate}}The custom form used for this list. You can create a form <a href="/forms/{{id}}/create">here</a>.{{/translate}}</span>
</div>
</div>
<hr />
<div class="form-group">

View file

@ -0,0 +1,27 @@
<ol class="breadcrumb">
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
<li><a href="/lists/">{{#translate}}Lists{{/translate}}</a></li>
<li><a href="/lists/view/{{list.id}}">{{list.name}}</a></li>
<li><a href="/forms/{{list.id}}">{{#translate}}Custom Forms{{/translate}}</a></li>
<li class="active">{{#translate}}Create Form{{/translate}}</li>
</ol>
<h2>{{list.name}} <small>{{#translate}}Create Custom Form{{/translate}}</small></h2>
<hr>
<form class="form-horizontal" method="post" action="/forms/{{list.id}}/create">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<div class="form-group">
<label for="name" class="col-sm-2 control-label">{{#translate}}Form Name{{/translate}}</label>
<div class="col-sm-10">
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="{{#translate}}Form Name{{/translate}}" required>
</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-plus"></i> {{#translate}}Add Form{{/translate}}</button>
</div>
</div>
</form>

283
views/lists/forms/edit.hbs Normal file
View file

@ -0,0 +1,283 @@
<ol class="breadcrumb">
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
<li><a href="/lists/">{{#translate}}Lists{{/translate}}</a></li>
<li><a href="/lists/view/{{list.id}}">{{list.name}}</a></li>
<li><a href="/forms/{{list.id}}">{{#translate}}Custom Forms{{/translate}}</a></li>
<li class="active">{{#translate}}Edit Form{{/translate}}</li>
</ol>
<h2>{{list.name}} <small>{{#translate}}Edit Custom Form{{/translate}}</small> <a class="btn btn-default btn-xs" href="/forms/{{list.id}}" role="button"><span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span> {{#translate}}Back to forms{{/translate}}</a></h2>
<hr>
<form method="post" class="delete-form" id="forms-delete" action="/forms/{{list.id}}/delete">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="id" value="{{form.id}}">
</form>
<form class="form-horizontal" method="post" id="forms-update" action="/forms/{{list.id}}/edit">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="id" value="{{form.id}}">
<input type="hidden" name="fields_shown_on_subscribe" value="{{form.fieldsShownOnSubscribe}}">
<input type="hidden" name="fields_shown_on_manage" value="{{form.fieldsShownOnManage}}">
<div class="form-group">
<label for="name" class="col-sm-2 control-label">{{#translate}}Form Name{{/translate}}</label>
<div class="col-sm-10">
<input type="text" class="form-control input-lg" name="name" id="name" value="{{form.name}}" placeholder="{{#translate}}Form Name{{/translate}}" required>
</div>
</div>
<div class="form-group">
<label for="form-description" class="col-sm-2 control-label">{{#translate}}Description{{/translate}}</label>
<div class="col-sm-10">
<textarea class="form-control" id="form-description" name="description" rows="3" placeholder="{{#translate}}Optional comments about this form{{/translate}}">{{form.description}}</textarea>
</div>
</div>
<div class="form-group">
<label for="previewLinks" class="col-sm-2 control-label">{{#translate}}Form Preview{{/translate}}</label>
<div class="col-sm-10" id="previewLinks">
<div class="help-block">
<small>
{{#translate}}Note: These links are solely for a quick preview. If you submit a preview form you'll get redirected to the list's default form.{{/translate}}
</small>
</div>
<p>
<a href="/subscription/{{list.cid}}?fid={{form.id}}" target="_blank">{{#translate}}Subscribe{{/translate}}</a>
|
<a href="/subscription/{{list.cid}}/confirm-notice?fid={{form.id}}" target="_blank">{{#translate}}Confirm Notice{{/translate}}</a>
|
<a href="/subscription/{{list.cid}}/updated-notice?fid={{form.id}}" target="_blank">{{#translate}}Updated Notice{{/translate}}</a>
|
<a href="/subscription/{{list.cid}}/unsubscribe-notice?fid={{form.id}}" target="_blank">{{#translate}}Unsubscribed Notice{{/translate}}</a>
{{#if testUsers}}
|
<a href="/subscription/{{list.cid}}/manage/{{testUsers.0.cid}}?fid={{form.id}}" target="_blank">{{#translate}}Manage{{/translate}}</a>
|
<a href="/subscription/{{list.cid}}/manage-address/{{testUsers.0.cid}}?fid={{form.id}}" target="_blank">{{#translate}}Manage Address{{/translate}}</a>
{{else}}
|
<small class="text-muted">{{#translate}}Create a test user for additional options{{/translate}}</small>
{{/if}}
</p>
</div>
</div>
<p><br></p>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active"><a href="#templates" aria-controls="templates" role="tab" data-toggle="tab">{{#translate}}Templates{{/translate}}</a></li>
<li role="presentation"><a href="#fields" aria-controls="fields" role="tab" data-toggle="tab">{{#translate}}Fields{{/translate}}</a></li>
</ul>
</div>
</div>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="templates">
<div class="form-group">
<label for="form-header" class="col-sm-2 control-label">{{#translate}}Edit{{/translate}}</label>
<div class="col-sm-10">
<select class="form-control" id="templateSelect">
{{#each templateOptgroups}}
<optgroup label="{{label}}">
{{#each opts}}
<option value="{{name}}">{{label}}</option>
{{/each}}
</optgroup>
{{/each}}
</select>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var forceAceRender = function(name) {
var div = $('.form-group.template.' + name).find('.ace_editor')[0];
div.env && div.env.editor && div.env.editor.resize(true);
};
$('#templateSelect').on('change', function() {
$('.form-group.template').hide();
var name = $(this).val();
$('.form-group.template.' + name).show();
forceAceRender(name);
});
$('ul.nav-tabs > li > a').on('shown.bs.tab', function(e) {
var id = $(e.target).attr('href').substr(1);
id === 'templates' && forceAceRender($('#templateSelect').val());
});
});
</script>
{{#each templateOptgroups}}
{{#each opts}}
<div class="form-group template {{name}}" {{#unless isLayout}}style="display: none;"{{/unless}}>
<div class="col-sm-offset-2 col-sm-10">
<div class="help-block" style="margin-top: -8px;">
<small>{{#if help}}{{{help}}}{{else}}&nbsp;{{/if}}</small>
</div>
<div class="code-editor-{{type}}" style="height: 700px; border: 1px solid #ccc;"></div>
<input type="hidden" name="{{name}}" value="{{value}}">
</div>
</div>
{{/each}}
{{/each}}
</div>
<!-- Fields -->
<style>
ul.fields {
min-height: 54px;
border: 1px dashed #ccc;
margin: 0;
padding: 11px 10px 10px;
background: #efefef;
}
ul.fields li {
list-style: none;
margin: -1px 0 0;
padding: 0 10px;
background: #fff;
border: 1px solid #ccc;
line-height: 30px;
white-space: nowrap;
overflow: hidden;
cursor: move;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
jQuery.get('/javascript/jquery-ui-1.12.1.min.js', undefined, function() {
$('.fieldsHiddenOnSubscribe, .fieldsShownOnSubscribe').sortable({
connectWith: '.connectedSortableSubscribe'
}).disableSelection();
$('.fieldsHiddenOnManage, .fieldsShownOnManage').sortable({
connectWith: '.connectedSortableManage'
}).disableSelection();
$('#forms-update').on('submit', function(e) {
var s = [];
var m = [];
$('.fieldsShownOnSubscribe > li').each(function() {
s.push($(this).data('field-id'));
});
$('.fieldsShownOnManage > li').each(function() {
m.push($(this).data('field-id'));
});
$('input[name=fields_shown_on_subscribe]').val(s.join(','));
$('input[name=fields_shown_on_manage]').val(m.join(','));
});
}, 'script');
});
</script>
<div role="tabpanel" class="tab-pane" id="fields">
<div class="form-group">
<label for="form-fields" class="col-sm-2 control-label">{{#translate}}Form Fields{{/translate}}</label>
<div class="col-sm-10">
<div class="row">
<div class="col-sm-6">
<h6>{{#translate}}Fields hidden on subscription page:{{/translate}}</h6>
<ul class="fields fieldsHiddenOnSubscribe connectedSortableSubscribe">
{{#each fieldsHiddenOnSubscribe}}
<li class="ui-state-default" data-field-id="{{id}}">
{{name}}
<span style="font-size: 10px; color: #aaa; float: right;">{{type}}</span>
</li>
{{/each}}
</ul>
<h6>{{#translate}}Fields shown on subscription page:{{/translate}}</h6>
<ul class="fields fieldsShownOnSubscribe connectedSortableSubscribe">
{{#each fieldsShownOnSubscribe}}
<li class="ui-state-default" data-field-id="{{id}}">
{{name}}
<span style="font-size: 10px; color: #aaa; float: right;">{{type}}</span>
</li>
{{/each}}
</ul>
<br>
</div>
<div class="col-sm-6">
<h6>{{#translate}}Fields hidden on preferences page:{{/translate}}</h6>
<ul class="fields fieldsHiddenOnManage connectedSortableManage">
{{#each fieldsHiddenOnManage}}
<li class="ui-state-default" data-field-id="{{id}}">
{{name}}
<span style="font-size: 10px; color: #aaa; float: right;">{{type}}</span>
</li>
{{/each}}
</ul>
<h6>{{#translate}}Fields shown on preferences page:{{/translate}}</h6>
<ul class="fields fieldsShownOnManage connectedSortableManage">
{{#each fieldsShownOnManage}}
<li class="ui-state-default" data-field-id="{{id}}">
{{name}}
<span style="font-size: 10px; color: #aaa; float: right;">{{type}}</span>
</li>
{{/each}}
</ul>
<br>
</div>
</div>
</div>
</div>
</div>
</div><!-- end .tab-content -->
<hr>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="pull-right">
<button type="submit" form="forms-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> {{#translate}}Delete Form{{/translate}}</button>
</div>
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update{{/translate}}</button>
</div>
</div>
</form>
<script src="/javascript/cookie.2.1.3.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Remember Tab
var tab = Cookies.get('tab');
tab && $('ul.nav-tabs > li > a[href="#' + tab + '"]').click();
$('ul.nav-tabs > li > a').on('shown.bs.tab', function(e) {
var id = $(e.target).attr('href').substr(1);
Cookies.set('tab', id, { expires: 7, path: '' });
});
// Remember Template
var tmpl = Cookies.get('tmpl');
tmpl && $('#templateSelect').val(tmpl).trigger('change');
$('#templateSelect').on('change', function() {
Cookies.set('tmpl', $(this).val(), { expires: 7, path: '' });
});
$('a.mjml-documentation')
.attr('href', 'https://mjml.io/documentation/')
.attr('target', '_blank')
.attr('rel', 'noreferrer')
});
</script>

View file

@ -0,0 +1,87 @@
<ol class="breadcrumb">
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
<li><a href="/lists/">{{#translate}}Lists{{/translate}}</a></li>
<li><a href="/lists/view/{{list.id}}">{{list.name}}</a></li>
<li class="active">{{#translate}}Custom Forms{{/translate}}</li>
</ol>
<div class="pull-right">
<a class="btn btn-primary" href="/forms/{{list.id}}/create" role="button"><i class="glyphicon glyphicon-plus"></i> {{#translate}}Create Custom Form{{/translate}}</a>
</div>
<h2>{{list.name}} <small>{{#translate}}Custom Forms{{/translate}}</small></h2>
<hr>
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead>
<th style="width: auto;" class="text-right">
#
</th>
<th style="width: 30%;">
{{#translate}}Name{{/translate}}
</th>
<th style="width: 60%;">
{{#translate}}Description{{/translate}}
</th>
<th>
&nbsp;
</th>
</thead>
<tbody>
{{#each customForms}}
<tr>
<th scope="row" class="text-right">
{{index}}
</th>
<td>
{{#if isDefaultForm}}
<span class="glyphicon glyphicon-star" style="color: #DE4320; font-size: .8em; font-weight: bold; padding-right: 2px;"></span>
{{else}}
<span class="glyphicon glyphicon-star-empty" style="color: #ccc; font-size: .8em; padding-right: 2px;"></span>
{{/if}}
{{name}}
</td>
<td class="text-muted">
{{description}}
</td>
<td class="text-center">
<a href="/forms/{{../list.id}}/edit/{{id}}">
{{#translate}}Edit{{/translate}}
</a>
</td>
</tr>
{{/each}}
{{#unless customForms}}
<tr>
<td colspan="6">
{{#translate}}No data available in table{{/translate}}
</td>
</tr>
{{/unless}}
</tbody>
</table>
</div>
<form class="form-inline" method="post" action="/lists/edit?next=%2Fforms%2F{{list.id}}">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="id" value="{{list.id}}" />
<input type="hidden" name="name" value="{{list.name}}" />
<div class="form-group">
<label for="default_form" class="control-label" style="color: #666; font-weight: normal;">{{#translate}}The default form for this list is:{{/translate}}</label>
&nbsp;
<select class="form-control input-sm" id="default_form" name="default_form">
<option value="0">{{#translate}}Default Mailtrain Form{{/translate}}</option>
{{#each customForms}}
<option value="{{id}}" {{#if isDefaultForm}}selected{{/if}}>{{name}}</option>
{{/each}}
</select>
<button type="submit" class="btn btn-default btn-sm">{{#translate}}Update{{/translate}}</button>
</div>
</form>
<p><br></p>

View file

@ -14,34 +14,33 @@
<div class="table-responsive">
<table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="0,1,1,1,0,0">
<thead>
<th class="col-md-1">
<th>
#
</th>
<th>
{{#translate}}Name{{/translate}}
</th>
<th class="col-md-2">
<th>
{{#translate}}ID{{/translate}}
</th>
<th class="col-md-1">
<th>
{{#translate}}Subscribers{{/translate}}
</th>
<th>
{{#translate}}Description{{/translate}}
</th>
<th class="col-md-1">
<th>
&nbsp;
</th>
</thead>
{{#if rows}}
<tbody>
{{#each rows}}
<tr>
<th scope="row">
{{index}}
</th>
<td>
<td style="width: 30%;">
<span class="glyphicon glyphicon-list-alt" aria-hidden="true"></span>
<a href="/lists/view/{{id}}">
{{name}}
@ -53,11 +52,10 @@
<td class="text-center">
{{subscribers}}
</td>
<td class="text-muted">
<td class="text-muted" style="width: 70%;">
{{description}}
</td>
<td>
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>
<a href="/lists/edit/{{id}}">
{{#translate}}Edit{{/translate}}
</a>

View file

@ -13,6 +13,7 @@
{{#translate}}List Actions{{/translate}} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="/forms/{{id}}" role="button"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> {{#translate}}Custom Forms{{/translate}}</a></li>
<li><a href="/fields/{{id}}" role="button"><span class="glyphicon glyphicon-tasks" aria-hidden="true"></span> {{#translate}}Custom Fields{{/translate}}</a></li>
<li><a href="/segments/{{id}}" role="button"><span class="glyphicon glyphicon-filter" aria-hidden="true"></span> {{#translate}}Segments{{/translate}}</a></li>
<li><a href="/lists/edit/{{id}}" role="button"><span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> {{#translate}}Edit List{{/translate}}</a></li>
@ -41,7 +42,8 @@
<form class="form-inline" method="get">
<div class="form-group">
<label for="exampleInputName2">{{#translate}}Segment{{/translate}}</label>
<select name="segment" class="form-control">
&nbsp;
<select name="segment" class="form-control input-sm">
<option value="0">All Subscriptions</option>
{{#if segments}}
<optgroup label="{{#translate}}Segments{{/translate}}">
@ -55,7 +57,7 @@
</optgroup>
</select>
</div>
<button type="submit" class="btn btn-default"><span class="glyphicon glyphicon-filter" aria-hidden="true"></span> {{#translate}}Filter{{/translate}}</button>
<button type="submit" class="btn btn-default btn-sm"><span class="glyphicon glyphicon-filter" aria-hidden="true"></span> {{#translate}}Filter{{/translate}}</button>
</form>
<div class="clearfix"></div>
</div>
@ -75,7 +77,7 @@
<table data-topic-url="/lists" data-topic-id="{{id}}" data-sort-column="1" data-sort-order="asc" {{#if useSegment}} data-topic-args="segment={{useSegment}}" {{/if}} class="table table-bordered table-hover data-table-ajax display nowrap" width="100%" data-row-sort="0,1,1,1{{customSort}},1,1,0">
<thead>
<tr>
<th class="col-md-1">
<th>
#
</th>
<th>

View file

@ -0,0 +1 @@
{{flash_messages}}

View file

@ -1,17 +0,0 @@
<div class="alert alert-warning alert-dismissible" role="alert" id="js-warning">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<strong>{{#translate}}Warning!{{/translate}}</strong> {{#translate}}If JavaScript was not enabled then no confirmation message was sent{{/translate}}
</div>
<script>
document.getElementById('js-warning').style.display = 'none';
</script>
<h2>{{#translate}}Almost finished.{{/translate}}</h2>
<p>{{#translate}}We need to confirm your email address. To complete the subscription process, please click the link in the email we just sent you.{{/translate}}</p>
<p>
<a class="btn btn-primary" href="{{homepage}}" role="button">
<span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span> {{#translate}}return to our website{{/translate}}
</a>
</p>

View file

@ -1,79 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}">
<meta name="author" content="Andris Reinman">
<link rel="icon" href="/favicon.ico">
<title>Mailtrain</title>
<!-- Bootstrap core CSS -->
<!--
<link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
-->
<!-- -->
<link rel="stylesheet" href="/bootstrap/themes/flatly.min.css">
<!-- -->
<link rel="stylesheet" href="/css/narrow.css">
<link rel="stylesheet" href="/datepicker/css/bootstrap-datepicker3.css">
{{#if useEditor}}
<link rel="stylesheet" href="/summernote/summernote.css">
{{/if}}
</head>
<body>
<div class="container">
<div class="header clearfix">
<h1 class="text-muted">{{title}}</h1>
</div>
{{flash_messages}} {{{body}}}
</div>
<script src="/javascript/jquery-2.2.1.min.js"></script>
<script src="/bootstrap/js/bootstrap.min.js"></script>
<script src="/datepicker/js/bootstrap-datepicker.min.js"></script>
<script src="/datatables/datatables.min.js"></script>
<script src="/moment/moment.min.js"></script>
<script src="/moment/moment-timezone-with-data.min.js"></script>
<script src="/javascript/tables.js"></script>
{{#if useEditor}}
<script src="/summernote/summernote.min.js"></script>
<script src="/javascript/editor.js"></script>
{{/if}}
{{#if uaCode}}
<script>
(function(i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r;
i[r] = i[r] || function() {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date();
a = s.createElement(o),
m = s.getElementsByTagName(o)[0];
a.async = 1;
a.src = g;
m.parentNode.insertBefore(a, m)
})(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga');
ga('create', '{{uaCode}}', 'auto');
ga('send', 'pageview');
</script>
{{/if}}
</body>
</html>

View file

@ -0,0 +1,62 @@
<mjml>
<mj-head>
<mj-title>{{title}}</mj-title>
<mj-font name="Lato" href="https://fonts.googleapis.com/css?family=Lato:400,700,400italic" />
<mj-attributes>
<mj-all font-size="15px" color="#2D3E4F" font-family="Lato, Helvetica, Arial, sans-serif" />
<mj-class name="h1" font-size="42px" line-height="68px" color="#b4bcc2" />
<mj-class name="h3" font-size="24px" line-height="32px" />
<mj-class name="p" font-size="15px" line-height="24px" />
<mj-class name="small" font-size="12px" line-height="16px" color="#999999" />
<mj-class name="hr" border-width="1px" border-style="solid" border-color="#e4e5e6" />
<mj-class name="button" font-size="16px" background-color="#2D3E4F" color="white" align="left" inner-padding="16px 24px" border-radius="6px" />
<mj-class name="footer-text" font-size="12px" line-height="18px" color="#999999" />
</mj-attributes>
</mj-head>
<mj-body>
<mj-container width="560" background-color="#ffffff">
<mj-raw>
{{#if isWeb}}
<style>
{{{formInputStyle}}}
.alert { max-width: 520px; font-family: Lato, Helvetica, Arial, sans-serif; }
</style>
{{/if}}
</mj-raw>
<mj-section padding-bottom="0">
<mj-column>
<mj-text mj-class="h1">
{{title}}
</mj-text>
<mj-divider mj-class="hr"/>
</mj-column>
</mj-section>
<mj-raw>
{{#if isWeb}}
{{> subscription_flash_messages}}
{{/if}}
</mj-raw>
{{{body}}}
<mj-section padding-top="0">
<mj-column>
<mj-text mj-class="small" font-style="italic">
<!-- {{defaultPostaddress}} -->
</mj-text>
</mj-column>
</mj-section>
<mj-raw>
{{#if isWeb}}
<!-- fixes https://github.com/mjmlio/mjml/issues/359 -->
{{> subscription_footer_scripts btnBgColor='#2D3E4F' btnBgColorHover='#1A242F'}}
{{/if}}
</mj-raw>
</mj-container>
</mj-body>
</mjml>

View file

@ -0,0 +1,17 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Please Confirm Subscription{{/translate}}
</mj-text>
<mj-button mj-class="button" href="{{confirmUrl}}">
{{#translate}}Yes, subscribe me to this list{{/translate}}
</mj-button>
<mj-text mj-class="p">
{{#translate}}If you received this email by mistake, simply delete it. You won't be subscribed if you don't click the confirmation link above.{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}For questions about this list, please contact:{{/translate}}
<br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a>
</mj-text>
</mj-column>
</mj-section>

View file

@ -0,0 +1,18 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Subscription Confirmed{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}Your subscription to our list has been confirmed{{/translate}}. {{#translate}}If you want to modify your subscription then you can {{/translate}}
<a href="{{preferencesUrl}}">{{#translate}}manage your preferences{{/translate}}</a> {{#translate}}or{{/translate}} <a href="{{unsubscribeUrl}}">{{#translate}}unsubscribe here{{/translate}}</a>.
</mj-text>
<mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
</mj-button>
<mj-text mj-class="p">
{{#translate}}For questions about this list, please contact:{{/translate}}
<br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a>
</mj-text>
</mj-column>
</mj-section>

View file

@ -0,0 +1,17 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}You Are Now Unsubscribed{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}We have removed your email address from our list{{/translate}}. {{#translate}}If you unsubscribed by mistake, you can re-subscribe at:{{/translate}}
</mj-text>
<mj-button mj-class="button" href="{{subscribeUrl}}">
{{#translate}}Subscribe{{/translate}}
</mj-button>
<mj-text mj-class="p">
{{#translate}}For questions about this list, please contact:{{/translate}}
<br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a>
</mj-text>
</mj-column>
</mj-section>

View file

@ -1,5 +1,5 @@
{{{title}}}
{{#translate}}You are now unsubscribed{{/translate}}
{{#translate}}You Are Now Unsubscribed{{/translate}}
========================
{{#translate}}We have removed your email address from our list.{{/translate}}

View file

@ -1,25 +0,0 @@
<h2>{{#translate}}Update your Email Address{{/translate}}</h2>
<form method="post" action="/subscription/{{lcid}}/manage-address">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="cid" value="{{cid}}">
<div class="form-group">
<label for="email">{{#translate}}Existing Email Address{{/translate}}</label>
<input type="email" class="form-control" name="email" id="email" placeholder="" value="{{email}}" readonly>
</div>
<div class="form-group">
<label for="email-new">{{#translate}}New Email Address{{/translate}}</label>
<input type="email" class="form-control" name="email-new" id="email-new" placeholder="{{#translate}}Your new email address{{/translate}}" value="{{email}}">
</div>
<p>
<small>{{#translate}}You will receive a confirmation request to your new email address that you need to accept before your email is actually changed{{/translate}}</small>
</p>
<div class="form-group">
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update Email Address{{/translate}}</button>
</div>
</form>

View file

@ -1,129 +0,0 @@
<h2>{{#translate}}Update your preferences{{/translate}}</h2>
{{#if hasPubkey}}
<form method="post" id="download-pubkey" action="/subscription/publickey">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="cid" value="{{cid}}">
</form>
{{/if}}
<form method="post" action="/subscription/{{lcid}}/manage">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="cid" value="{{cid}}">
<input type="hidden" class="tz-detect" name="tz" id="tz" value="{{tz}}">
<div class="form-group">
<label for="email">{{#translate}}Email Address{{/translate}}</label>
<div class="input-group">
<input type="email" class="form-control" name="email" id="email" placeholder="" value="{{email}}" readonly>
<div class="input-group-addon"><a href="/subscription/{{lcid}}/manage-address/{{cid}}">{{#translate}}want to change it?{{/translate}}</a></div>
</div>
</div>
<div class="form-group">
<label for="first-name">{{#translate}}First Name{{/translate}}</label>
<input type="text" class="form-control" name="first-name" id="first-name" placeholder="" value="{{firstName}}">
</div>
<div class="form-group">
<label for="last-name">{{#translate}}Last Name{{/translate}}</label>
<input type="text" class="form-control" name="last-name" id="last-name" placeholder="" value="{{lastName}}">
</div>
{{#each customFields}}
<div class="form-group">
<label>{{name}}</label>
{{#if typeText}}
<input type="text" class="form-control" name="{{column}}" value="{{value}}">
{{/if}}
{{#if typeNumber}}
<input type="number" class="form-control" name="{{column}}" value="{{value}}">
{{/if}}
{{#if typeWebsite}}
<input type="url" class="form-control" name="{{column}}" value="{{value}}">
{{/if}}
{{#if typeLongtext}}
<textarea class="form-control" rows="3" name="{{column}}">{{value}}</textarea>
{{/if}}
{{#if typeJson}}
<textarea class="form-control gpg-text" rows="3" name="{{column}}" placeholder="{&quot;data&quot;:&quot;value&quot;}">{{value}}</textarea>
{{/if}}
{{#if typeGpg}}
{{#if ../hasPubkey}}
<div class="pull-right">
<button type="submit" class="btn btn-link btn-xs" form="download-pubkey"><span class="glyphicon glyphicon-cloud-download" aria-hidden="true"></span> {{#translate}}Download signature verification key{{/translate}}</button>
</div>
{{/if}}
<textarea class="form-control gpg-text" rows="3" name="{{column}}" placeholder="{{#translate}}Begins with{{/translate}} &#39;-----BEGIN PGP PUBLIC KEY BLOCK-----&#39;">{{value}}</textarea>
<span class="help-block">{{#translate}}Insert your GPG public key here to encrypt messages sent to your address{{/translate}} <em>({{#translate}}optional{{/translate}})</em></span>
{{/if}}
{{#if typeDateUs}}
<div class="input-group date fm-date-us">
<input type="text" class="form-control" name="{{column}}" placeholder="MM/DD/YYYY" value="{{value}}"><span class="input-group-addon"><i class="glyphicon glyphicon-th"></i></span>
</div>
{{/if}}
{{#if typeDateEur}}
<div class="input-group date fm-date-eur">
<input type="text" class="form-control" name="{{column}}" placeholder="DD/MM/YYYY" value="{{value}}"><span class="input-group-addon"><i class="glyphicon glyphicon-th"></i></span>
</div>
{{/if}}
{{#if typeBirthdayUs}}
<div class="input-group date fm-birthday-us">
<input type="text" class="form-control" name="{{column}}" placeholder="MM/DD" value="{{value}}"><span class="input-group-addon"><i class="glyphicon glyphicon-th"></i></span>
</div>
{{/if}}
{{#if typeBirthdayEur}}
<div class="input-group date fm-birthday-eur">
<input type="text" class="form-control" name="{{column}}" placeholder="DD/MM" value="{{value}}"><span class="input-group-addon"><i class="glyphicon glyphicon-th"></i></span>
</div>
{{/if}}
{{#if typeDropdown}}
<select name="{{key}}" class="form-control">
<option value="">
{{#translate}}Select{{/translate}}
</option>
{{#each options}}
<option value="{{column}}" {{#if value}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
{{/if}}
{{#if typeRadio}}
{{#each options}}
<div class="radio">
<label>
<input type="radio" name="{{../key}}" value="{{column}}" {{#if value}} checked {{/if}}> {{name}}
</label>
</div>
{{/each}}
{{/if}}
{{#if typeCheckbox}}
{{#each options}}
<div class="checkbox">
<label>
<input type="checkbox" name="{{column}}" value="1" {{#if value}} checked {{/if}}> {{name}}
</label>
</div>
{{/each}}
{{/if}}
</div>
{{/each}}
<div class="form-group">
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update Profile{{/translate}}</button> or <a href="/subscription/{{lcid}}/unsubscribe/{{cid}}">{{#translate}}Unsubscribe{{/translate}}</a>
</div>
</form>

View file

@ -0,0 +1,143 @@
{{#each customFields}}
{{#if typeSubsciptionEmail}}
<div class="form-group email">
<label for="email">{{#translate}}Email Address{{/translate}}</label>
{{#if ../isManagePreferences}}
<div class="input-group">
<input type="email" name="email" id="email" placeholder="" value="{{../email}}" readonly>
<div class="input-group-addon"><a href="/subscription/{{../lcid}}/manage-address/{{../cid}}">{{#translate}}want to change it?{{/translate}}</a></div>
</div>
{{else}}
<input type="email" name="email" id="email" placeholder="" value="{{../email}}" required>
{{/if}}
</div>
{{/if}}
{{#if typeFirstName}}
<div class="form-group first-name">
<label for="first-name">{{#translate}}First Name{{/translate}}</label>
<input type="text" name="first-name" id="first-name" placeholder="" value="{{../firstName}}">
</div>
{{/if}}
{{#if typeLastName}}
<div class="form-group last-name">
<label for="last-name">{{#translate}}Last Name{{/translate}}</label>
<input type="text" name="last-name" id="last-name" placeholder="" value="{{../lastName}}">
</div>
{{/if}}
{{#if typeText}}
<div class="form-group text {{column}}">
<label for="{{column}}">{{name}}</label>
<input type="text" name="{{column}}" value="{{value}}">
</div>
{{/if}}
{{#if typeNumber}}
<div class="form-group number {{column}}">
<label for="{{column}}">{{name}}</label>
<input type="number" name="{{column}}" value="{{value}}">
</div>
{{/if}}
{{#if typeWebsite}}
<div class="form-group url {{column}}">
<label for="{{column}}">{{name}}</label>
<input type="url" name="{{column}}" value="{{value}}">
</div>
{{/if}}
{{#if typeLongtext}}
<div class="form-group longtext {{column}}">
<label for="{{column}}">{{name}}</label>
<textarea rows="3" name="{{column}}">{{value}}</textarea>
</div>
{{/if}}
{{#if typeJson}}
<div class="form-group json {{column}}">
<label for="{{column}}">{{name}}</label>
<textarea class="gpg-text" rows="3" name="{{column}}" placeholder="{&quot;data&quot;:&quot;value&quot;}">{{value}}</textarea>
</div>
{{/if}}
{{#if typeGpg}}
<div class="form-group gpg {{column}}">
<label for="{{column}}">{{name}}</label>
{{#if ../hasPubkey}}
<button class="btn-download-pubkey" type="submit" form="download-pubkey">{{#translate}}Download signature verification key{{/translate}}</button>
{{/if}}
<textarea class="form-control gpg-text" rows="4" name="{{column}}" placeholder="{{#translate}}Begins with{{/translate}} &#39;-----BEGIN PGP PUBLIC KEY BLOCK-----&#39;">{{value}}</textarea>
<span class="help-block">
{{#translate}}Insert your GPG public key here to encrypt messages sent to your address{{/translate}} <em>({{#translate}}optional{{/translate}})</em>
</span>
</div>
{{/if}}
{{#if typeDateUs}}
<div class="form-group date fm-date-us {{column}}">
<label for="{{column}}">{{name}}</label>
<input type="text" name="{{column}}" placeholder="MM/DD/YYYY" value="{{value}}">
</div>
{{/if}}
{{#if typeDateEur}}
<div class="form-group date fm-date-eur {{column}}">
<label for="{{column}}">{{name}}</label>
<input type="text" name="{{column}}" placeholder="DD/MM/YYYY" value="{{value}}">
</div>
{{/if}}
{{#if typeBirthdayUs}}
<div class="form-group date fm-birthday-us {{column}}">
<label for="{{column}}">{{name}}</label>
<input type="text" name="{{column}}" placeholder="MM/DD" value="{{value}}">
</div>
{{/if}}
{{#if typeBirthdayEur}}
<div class="form-group date fm-birthday-eur {{column}}">
<label for="{{column}}">{{name}}</label>
<input type="text" name="{{column}}" placeholder="DD/MM" value="{{value}}">
</div>
{{/if}}
{{#if typeDropdown}}
<div class="form-group dropdown {{key}}">
<label for="{{key}}">{{name}}</label>
<select name="{{key}}" class="form-control">
<option value="">
{{#translate}}Select{{/translate}}
</option>
{{#each options}}
<option value="{{column}}" {{#if value}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
</div>
{{/if}}
{{#if typeRadio}}
<div class="form-group radio {{key}}">
<label for="{{key}}">{{name}}</label>
{{#each options}}
<label class="label-radio">
<input type="radio" name="{{../key}}" value="{{column}}" {{#if value}} checked {{/if}}> {{name}}
</label>
{{/each}}
</div>
{{/if}}
{{#if typeCheckbox}}
<div class="form-group checkbox">
<label>{{name}}</label>
{{#each options}}
<label class="label-checkbox">
<input type="checkbox" name="{{column}}" value="1" {{#if value}} checked {{/if}}> {{name}}
</label>
{{/each}}
</div>
{{/if}}
{{/each}}

View file

@ -0,0 +1,20 @@
{{{flashMessages}}}
{{#if isConfirmNotice}}
<div class="alert alert-warning js-warning" role="alert">
<strong>{{#translate}}Warning!{{/translate}}</strong> {{#translate}}If JavaScript was not enabled then no confirmation message was sent{{/translate}}
</div>
{{/if}}
{{#if needsJsWarning}}
<div class="alert alert-danger js-warning" role="alert">
<strong>{{#translate}}Warning!{{/translate}}</strong>
{{#translate}}JavaScript must be enabled in order for this form to work{{/translate}}
</div>
{{/if}}
<script>
document.querySelectorAll('.js-warning').forEach(function(el) {
el.style.display = 'none';
});
</script>

View file

@ -0,0 +1,56 @@
<script>
var btnBgColor = '{{btnBgColor}}';
var btnBgColorHover = '{{btnBgColorHover}}';
if (typeof moment !== 'undefined' && moment.tz) {
(function () {
var tz = moment.tz.guess();
if (tz) {
document.querySelectorAll('.tz-detect').forEach(function(el) {
el.value = tz;
});
}
})();
}
document.querySelectorAll('a[href="#submit"]').forEach(function(a) {
a.onclick = function() {
var form = document.getElementById('main-form');
form && form.submit();
return false;
};
});
// Fixes MJML Button until they do ...
// https://github.com/mjmlio/mjml/issues/359
if (window.btnBgColor) {
(function() {
var s = document.createElement('style');
var c = document.createTextNode(
'.td-btn:hover { background-color: ' + window.btnBgColorHover + '; }' +
'.td-btn { cursor: pointer !important; }' +
'.a-btn { background-color: transparent !important; }'
);
s.appendChild(c);
document.getElementsByTagName('head')[0].appendChild(s);
document.querySelectorAll('a').forEach(function(a) {
if (a.parentNode.getAttribute('bgcolor') === window.btnBgColor) {
a.target = '_self';
a.className += 'a-btn';
a.parentNode.className += 'td-btn';
a.parentNode.onclick = function() {
a.click();
};
}
});
})();
}
</script>
<!-- custom scripts from config -->
{{#each customSubscriptionScripts}}
<script src="{{this}}"></script>
{{/each}}
{{> tracking_scripts}}

View file

@ -0,0 +1,22 @@
<form id="main-form" method="post" action="/subscription/{{lcid}}/manage-address">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="cid" value="{{cid}}">
<div class="form-group">
<label for="email">{{#translate}}Existing Email Address{{/translate}}</label>
<input type="email" name="email" id="email" placeholder="" value="{{email}}" readonly>
</div>
<div class="form-group">
<label for="email-new">{{#translate}}New Email Address{{/translate}}</label>
<input type="email" name="email-new" id="email-new" placeholder="{{#translate}}Your new email address{{/translate}}" value="{{email}}">
</div>
<p>
{{#translate}}You will receive a confirmation request to your new email address that you need to accept before your email is actually changed{{/translate}}
</p>
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Update Email Address{{/translate}}</button>
</form>

View file

@ -0,0 +1,19 @@
{{#if hasPubkey}}
<form method="post" id="download-pubkey" action="/subscription/publickey" style="display: none;">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="cid" value="{{cid}}">
</form>
{{/if}}
<form id="main-form" method="post" action="/subscription/{{lcid}}/manage">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="cid" value="{{cid}}">
<input type="hidden" class="tz-detect" name="tz" id="tz" value="{{tz}}">
{{> subscription_custom_fields}}
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Update Profile{{/translate}}</button>
</form>
<script src="/moment/moment.min.js"></script>
<script src="/moment/moment-timezone-with-data.min.js"></script>

View file

@ -0,0 +1,24 @@
{{#if hasPubkey}}
<form method="post" id="download-pubkey" action="/subscription/publickey" style="display: none;">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="cid" value="{{cid}}">
</form>
{{/if}}
<form id="main-form" method="post" action="/subscription/{{cid}}/subscribe">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" class="tz-detect" name="tz" id="tz" value="{{tz}}">
<input type="hidden" name="address" value="">
<input type="hidden" name="sub" id="sub" value="">
{{> subscription_custom_fields}}
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Subscribe to list{{/translate}}</button>
</form>
<script>
document.getElementById('sub').value = new Date().getTime();
</script>
<script src="/moment/moment.min.js"></script>
<script src="/moment/moment-timezone-with-data.min.js"></script>

View file

@ -0,0 +1,20 @@
<form method="post" id="main-form" action="/subscription/{{lcid}}/unsubscribe">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="campaign" value="{{campaign}}">
<input type="hidden" name="cid" value="{{cid}}">
<div class="form-group">
<label for="email">{{#translate}}Email address{{/translate}}</label>
<input type="email" name="email" id="email" placeholder="" value="{{email}}" autofocus required>
</div>
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Unsubscribe{{/translate}}</button>
</form>
{{#if email}}
{{#if autosubmit}}
<script>
document.getElementById('main-form').submit();
</script>
{{/if}}
{{/if}}

View file

@ -1,142 +0,0 @@
{{#if hasPubkey}}
<form method="post" id="download-pubkey" action="/subscription/publickey">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="cid" value="{{cid}}">
</form>
{{/if}}
<div class="alert alert-warning alert-dismissible" role="alert" id="js-warning">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<strong>{{#translate}}Warning!{{/translate}}</strong>
{{#translate}}JavaScript must be enabled in order for the subscription form to work{{/translate}}
</div>
<script>
document.getElementById('js-warning').style.display = 'none';
</script>
<form method="post" action="/subscription/{{cid}}/subscribe">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" class="tz-detect" name="tz" id="tz" value="{{tz}}">
<input type="hidden" name="address" value="">
<input type="hidden" name="sub" id="sub" value="">
<div class="form-group">
<label for="email">{{#translate}}Email Address{{/translate}}</label>
<input type="email" class="form-control" name="email" id="email" placeholder="" value="{{email}}" required>
</div>
<div class="form-group">
<label for="first-name">{{#translate}}First Name{{/translate}}</label>
<input type="text" class="form-control" name="first-name" id="first-name" placeholder="" value="{{firstName}}">
</div>
<div class="form-group">
<label for="last-name">{{#translate}}Last Name{{/translate}}</label>
<input type="text" class="form-control" name="last-name" id="last-name" placeholder="" value="{{lastName}}">
</div>
{{#each customFields}}
<div class="form-group">
<label>{{name}}</label>
{{#if typeText}}
<input type="text" class="form-control" name="{{column}}" value="{{value}}">
{{/if}}
{{#if typeNumber}}
<input type="number" class="form-control" name="{{column}}" value="{{value}}">
{{/if}}
{{#if typeWebsite}}
<input type="url" class="form-control" name="{{column}}" value="{{value}}">
{{/if}}
{{#if typeLongtext}}
<textarea class="form-control" rows="3" name="{{column}}">{{value}}</textarea>
{{/if}}
{{#if typeJson}}
<textarea class="form-control gpg-text" rows="3" name="{{column}}" placeholder="{&quot;data&quot;:&quot;value&quot;}">{{value}}</textarea>
{{/if}}
{{#if typeGpg}}
{{#if ../hasPubkey}}
<div class="pull-right">
<button type="submit" class="btn btn-link btn-xs" form="download-pubkey"><span class="glyphicon glyphicon-cloud-download" aria-hidden="true"></span> {{#translate}}Download signature verification key{{/translate}}</button>
</div>
{{/if}}
<textarea class="form-control gpg-text" rows="3" name="{{column}}" placeholder="{{#translate}}Begins with{{/translate}} &#39;-----BEGIN PGP PUBLIC KEY BLOCK-----&#39;">{{value}}</textarea>
<span class="help-block">{{#translate}}Insert your GPG public key here to encrypt messages sent to your address{{/translate}} <em>({{#translate}}optional{{/translate}})</em></span>
{{/if}}
{{#if typeDateUs}}
<div class="input-group date fm-date-us">
<input type="text" class="form-control" name="{{column}}" placeholder="MM/DD/YYYY" value="{{value}}"><span class="input-group-addon"><i class="glyphicon glyphicon-th"></i></span>
</div>
{{/if}}
{{#if typeDateEur}}
<div class="input-group date fm-date-eur">
<input type="text" class="form-control" name="{{column}}" placeholder="DD/MM/YYYY" value="{{value}}"><span class="input-group-addon"><i class="glyphicon glyphicon-th"></i></span>
</div>
{{/if}}
{{#if typeBirthdayUs}}
<div class="input-group date fm-birthday-us">
<input type="text" class="form-control" name="{{column}}" placeholder="MM/DD" value="{{value}}"><span class="input-group-addon"><i class="glyphicon glyphicon-th"></i></span>
</div>
{{/if}}
{{#if typeBirthdayEur}}
<div class="input-group date fm-birthday-eur">
<input type="text" class="form-control" name="{{column}}" placeholder="DD/MM" value="{{value}}"><span class="input-group-addon"><i class="glyphicon glyphicon-th"></i></span>
</div>
{{/if}}
{{#if typeDropdown}}
<select name="{{key}}" class="form-control">
<option value="">
{{#translate}}Select{{/translate}}
</option>
{{#each options}}
<option value="{{column}}" {{#if value}} selected {{/if}}>{{name}}</option>
{{/each}}
</select>
{{/if}}
{{#if typeRadio}}
{{#each options}}
<div class="radio">
<label>
<input type="radio" name="{{../key}}" value="{{column}}" {{#if value}} checked {{/if}}> {{name}}
</label>
</div>
{{/each}}
{{/if}}
{{#if typeCheckbox}}
{{#each options}}
<div class="checkbox">
<label>
<input type="checkbox" name="{{column}}" value="1" {{#if value}} checked {{/if}}> {{name}}
</label>
</div>
{{/each}}
{{/if}}
</div>
{{/each}}
<div class="form-group" id="js-subscribe" style="display: none">
<button type="submit" class="btn btn-primary">{{#translate}}Subscribe to list{{/translate}}</button>
</div>
<script>
document.getElementById('js-subscribe').style.display = 'block';
</script>
</form>
<script>
document.getElementById('sub').value = new Date().getTime();
</script>

View file

@ -1,21 +0,0 @@
<h2>{{#translate}}Subscription Confirmed{{/translate}}</h2>
<p>
{{#translate}}Your subscription to our list has been confirmed.{{/translate}}
</p>
<p>
{{#translate}}Thank you for subscribing!{{/translate}}
</p>
<p>
<a class="btn btn-primary" href="{{homepage}}" role="button">
<span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span>
{{#translate}}continue to our website{{/translate}}
</a>
{{#translate}}or{{/translate}}
<a class="btn btn-primary" href="{{preferences}}" role="button">
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span>
{{#translate}}manage your preferences{{/translate}}
</a>
</p>

View file

@ -1,9 +0,0 @@
<h2>{{#translate}}Unsubscribe Successful{{/translate}}</h2>
<p>{{#translate}}You have been removed from:{{/translate}} {{title}}.</p>
<p>
<a class="btn btn-primary" href="{{homepage}}" role="button">
<span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span> {{#translate}}return to our website{{/translate}}
</a>
</p>

View file

@ -1,27 +0,0 @@
<h2>{{#translate}}Unsubscribe{{/translate}}</h2>
<p>
{{#translate}}Enter your email address to unsubscribe from:{{/translate}} {{title}}
</p>
<form method="post" id="unsubscribe-form" action="/subscription/{{lcid}}/unsubscribe">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="hidden" name="campaign" value="{{campaign}}">
<input type="hidden" name="cid" value="{{cid}}">
<div class="form-group">
<label for="email">{{#translate}}Email address{{/translate}}</label>
<input type="email" class="form-control" name="email" id="email" placeholder="" value="{{email}}" autofocus required>
</div>
<div class="form-group">
<button type="submit" id="unsubscribe-button" class="btn btn-primary">{{#translate}}Unsubscribe{{/translate}}</button>
</div>
</form>
{{#if email}}
{{#if autosubmit}}
<script src="/javascript/autosubmit.js"></script>
{{/if}}
{{/if}}

View file

@ -1,12 +0,0 @@
<h3>{{#translate}}Profile Updated{{/translate}}</h3>
<p>
{{#translate}}Your profile information has been updated.{{/translate}}
</p>
<p>
<a class="btn btn-primary" href="{{homepage}}" role="button">
<span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span>
{{#translate}}return to our website{{/translate}}
</a>
</p>

View file

@ -0,0 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Almost Finished{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}We need to confirm your email address. To complete the subscription process, please click the link in the email we just sent you.{{/translate}}
</mj-text>
<mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
</mj-button>
</mj-column>
</mj-section>

View file

@ -0,0 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Update Your Email Address{{/translate}}
</mj-text>
<mj-text>
{{> subscription_manage_address_form}}
</mj-text>
<mj-button mj-class="button" href="#submit">
{{#translate}}Update Email Address{{/translate}}
</mj-button>
</mj-column>
</mj-section>

View file

@ -0,0 +1,16 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Update Your Preferences{{/translate}}
</mj-text>
<mj-text>
{{> subscription_manage_form}}<!-- don't indent me! -->
</mj-text>
<mj-button mj-class="button" href="#submit">
{{#translate}}Update Profile{{/translate}}
</mj-button>
<mj-text mj-class="p">
<a href="/subscription/{{lcid}}/unsubscribe/{{cid}}">{{#translate}}Unsubscribe{{/translate}}</a>
</mj-text>
</mj-column>
</mj-section>

View file

@ -0,0 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Subscribe to List{{/translate}}
</mj-text>
<mj-text>
{{> subscription_subscribe_form}}<!-- don't indent me! -->
</mj-text>
<mj-button mj-class="button" href="#submit">
{{#translate}}Subscribe to list{{/translate}}
</mj-button>
</mj-column>
</mj-section>

View file

@ -0,0 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Subscription Confirmed{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}Your subscription to our list has been confirmed.{{/translate}}<br>{{#translate}}Thank you for subscribing!{{/translate}}
</mj-text>
<mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
</mj-button>
</mj-column>
</mj-section>

View file

@ -0,0 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Unsubscribe Successful{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}You have been removed from:{{/translate}} {{title}}.
</mj-text>
<mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
</mj-button>
</mj-column>
</mj-section>

View file

@ -0,0 +1,16 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Unsubscribe{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}Enter your email address to unsubscribe from:{{/translate}} {{title}}
</mj-text>
<mj-text>
{{> subscription_unsubscribe_form}}
</mj-text>
<mj-button mj-class="button" href="#submit">
{{#translate}}Unsubscribe{{/translate}}
</mj-button>
</mj-column>
</mj-section>

View file

@ -0,0 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Profile Updated{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}Your profile information has been updated.{{/translate}}
</mj-text>
<mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
</mj-button>
</mj-column>
</mj-section>