Halfway through extending subscriptions by selectable unsubscription process. Also contains changes towards better handling of scenarios when address is already subscribed.

This commit is contained in:
Tomas Bures 2017-04-30 10:51:47 -04:00
parent b0d51c7dad
commit 3783d7c2ce
27 changed files with 727 additions and 431 deletions

View file

@ -14,22 +14,30 @@ let allowedKeys = [
'fields_shown_on_manage',
'layout',
'form_input_style',
'mail_confirm_html',
'mail_confirm_text',
'web_subscribe',
'web_confirm_subscription_notice',
'mail_confirm_subscription_html',
'mail_confirm_subscription_text',
'mail_already_subscribed_html',
'mail_already_subscribed_text',
'web_subscribed_notice',
'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_manage_address',
'web_updated_notice',
'web_unsubscribe',
'web_updated_notice'
'web_confirm_unsubscription_notice',
'mail_confirm_unsubscription_html',
'mail_confirm_unsubscription_text',
'mail_confirm_address_change_html',
'mail_confirm_address_change_text',
'web_unsubscribed_notice',
'mail_unsubscription_confirmed_html',
'mail_unsubscription_confirmed_text'
];
module.exports.list = (listId, callback) => {
listId = Number(listId) || 0;

View file

@ -7,7 +7,16 @@ let segments = require('./segments');
let _ = require('../translate')._;
let tableHelpers = require('../table-helpers');
let allowedKeys = ['description', 'default_form', 'public_subscribe'];
const UnsubscriptionMode = {
ONE_STEP: 0,
TWO_STEP: 1,
MANUAL: 2,
MAX: 3
};
module.exports.UnsubscriptionMode = UnsubscriptionMode;
let allowedKeys = ['description', 'default_form', 'public_subscribe', 'unsubscription_mode'];
module.exports.list = (start, limit, callback) => {
tableHelpers.list('lists', ['*'], 'name', null, start, limit, callback);
@ -99,6 +108,63 @@ module.exports.get = (id, callback) => {
});
};
module.exports.update = (id, updates, callback) => {
updates = updates || {};
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing List ID')));
}
const data = tools.convertKeys(updates);
const keys = [];
const values = [];
// The update can be only partial when executed from forms/:list
if (!data.customFormChangeOnly) {
data.publicSubscribe = data.publicSubscribe ? 1 : 0;
data.unsubscriptionMode = Number(data.unsubscriptionMode);
let name = (data.name || '').toString().trim();
if (!name) {
return callback(new Error(_('List Name must be set')));
}
keys.push('name');
values.push(name);
}
Object.keys(data).forEach(key => {
let value = data[key].toString().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);
}
values.push(id);
connection.query('UPDATE lists SET ' + keys.map(key => key + '=?').join(', ') + ' WHERE id=? LIMIT 1', values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, result && result.affectedRows || false);
});
});
};
module.exports.create = (list, callback) => {
let data = tools.convertKeys(list);
@ -157,54 +223,6 @@ module.exports.create = (list, callback) => {
});
};
module.exports.update = (id, updates, callback) => {
updates = updates || {};
id = Number(id) || 0;
let data = tools.convertKeys(updates);
data.publicSubscribe = data.publicSubscribe ? 1 : 0;
let name = (data.name || '').toString().trim();
let keys = ['name'];
let values = [name];
if (id < 1) {
return callback(new Error(_('Missing List ID')));
}
if (!name) {
return callback(new Error(_('List Name must be set')));
}
Object.keys(data).forEach(key => {
let value = data[key].toString().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);
}
values.push(id);
connection.query('UPDATE lists SET ' + keys.map(key => key + '=?').join(', ') + ' WHERE id=? LIMIT 1', values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, result && result.affectedRows || false);
});
});
};
module.exports.delete = (id, callback) => {
id = Number(id) || 0;

View file

@ -88,108 +88,109 @@ module.exports.filter = (listId, request, columns, segmentId, callback) => {
};
module.exports.addConfirmation = (list, email, optInIp, data, callback) => {
module.exports.addConfirmation = (list, email, ip, data, callback) => {
let cid = shortid.generate();
tools.validateEmail(email, false, err => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
db.getConnection((err, connection) => {
let query = 'INSERT INTO confirmations (cid, list, email, ip, data) VALUES (?,?,?,?,?)';
connection.query(query, [cid, list.id, email, ip, JSON.stringify(data || {})], (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let query = 'INSERT INTO confirmations (cid, list, email, opt_in_ip, data) VALUES (?,?,?,?,?)';
connection.query(query, [cid, list.id, email, optInIp, JSON.stringify(data || {})], (err, result) => {
connection.release();
if (!result || !result.affectedRows) {
return callback(null, false);
}
fields.list(list.id, (err, fieldList) => {
if (err) {
return callback(err);
}
if (!result || !result.affectedRows) {
return callback(null, false);
}
let encryptionKeys = [];
fields.getRow(fieldList, data).forEach(field => {
if (field.type === 'gpg' && field.value) {
encryptionKeys.push(field.value.trim());
}
});
fields.list(list.id, (err, fieldList) => {
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl'], (err, configItems) => {
if (err) {
return callback(err);
}
let encryptionKeys = [];
fields.getRow(fieldList, data).forEach(field => {
if (field.type === 'gpg' && field.value) {
encryptionKeys.push(field.value.trim());
}
});
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl'], (err, configItems) => {
if (err) {
return callback(err);
setImmediate(() => {
if (data._skip) {
log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data));
return;
}
setImmediate(() => {
if (data._skip) {
log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data));
return;
// FIXME - move to router
const mailOpts = {
subject: _('%s: Please Confirm Subscription'),
confirmUrlRoute: '/subscription/confirm/',
templateType: 'subscription'
};
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(mailOpts.subject, list.name),
encryptionKeys
}, {
html,
text,
data: {
title: list.name,
contactAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
confirmUrl: urllib.resolve(configItems.serviceUrl, mailOpts.confirmUrlRoute + cid)
}
}, err => {
if (err) {
log.error('Subscription', err);
}
});
};
let text = {
template: 'subscription/mail-confirm-' + mailOpts.templateType + '-text.hbs'
};
let html = {
template: 'subscription/mail-confirm-' + mailOpts.templateType + '-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',
type: 'mjml'
};
helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => {
if (err) {
return sendMail(html, text);
}
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);
}
});
};
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) {
return sendMail(html, text);
}
sendMail(tmpl.html, tmpl.text);
});
sendMail(tmpl.html, tmpl.text);
});
return callback(null, cid);
});
return callback(null, cid);
});
});
});
});
};
module.exports.subscribe = (cid, optInIp, callback) => {
module.exports.processConfirmation = (cid, ip, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
@ -215,7 +216,11 @@ module.exports.subscribe = (cid, optInIp, callback) => {
subscription = {};
}
if (subscription.action === 'update' && subscription.subscriber) {
if (subscription._action === 'update') {
if (!subscription.subscriber) { // Something went terribly wrong and we don't have data that we have originally provided
return callback(new Error(_('Subscriber info corrupted or missing')));
}
// update email address instead of adding new
db.getConnection((err, connection) => {
if (err) {
@ -230,45 +235,57 @@ module.exports.subscribe = (cid, optInIp, callback) => {
}
connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => {
connection.release();
// reload full data from db in case it was an update, not insert
return module.exports.getById(listId, subscription.subscriber, callback);
return module.exports.getById(listId, subscription.subscriber, (err, subscriptionData) => {
return callback(err, subscriptionData, subscription._action);
});
});
});
});
return;
}
subscription.cid = cid;
subscription.list = listId;
subscription.email = email;
} else if (subscription._action === 'unsubscribe') {
// TODO
return;
let optInCountry = geoip.lookupCountry(optInIp) || null;
module.exports.insert(listId, {
email,
cid,
optInIp,
optInCountry,
status: 1
}, subscription, (err, result) => {
if (err) {
return callback(err);
}
} else if (subscription._action === 'subscribe') {
subscription.cid = cid;
subscription.list = listId;
subscription.email = email;
if (!result.entryId) {
return callback(new Error(_('Could not save subscription')));
}
db.getConnection((err, connection) => {
let optInCountry = geoip.lookupCountry(ip) || null;
module.exports.insert(listId, {
email,
cid,
optInIp: ip,
optInCountry,
status: 1
}, subscription, (err, result) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => {
connection.release();
// reload full data from db in case it was an update, not insert
return module.exports.getById(listId, result.entryId, callback);
if (!result.entryId) {
return callback(new Error(_('Could not save subscription')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => {
connection.release();
// reload full data from db in case it was an update, not insert
return module.exports.getById(listId, result.entryId, (err, subscriptionData) => {
return callback(err, subscriptionData, subscription._action);
});
});
});
});
});
} else {
return callback(new Error(util.format(_('Subscription request corrupted - action: %s'), subscription._action)));
}
});
});
};
@ -307,6 +324,7 @@ module.exports.insert = (listId, meta, subscription, callback) => {
}
});
// FIXME - see issue #218
fields.getValues(fields.getRow(fieldList, subscription, true, true, !!meta.partial), true).forEach(field => {
keys.push(field.key);
values.push(field.value);
@ -377,6 +395,8 @@ module.exports.insert = (listId, meta, subscription, callback) => {
queryArgs = values.concat(existing.id);
query = 'UPDATE `subscription__' + listId + '` SET ' + keys.map(key => '`' + key + '`=?') + ' WHERE id=? LIMIT 1';
}
console.log(query);
console.log(queryArgs);
connection.query(query, queryArgs, (err, result) => {
if (err) {
@ -1076,7 +1096,7 @@ module.exports.listImports = (listId, callback) => {
};
module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
module.exports.updateAddress = (list, cid, updates, ip, callback) => {
updates = tools.convertKeys(updates);
cid = (cid || '').toString().trim();
@ -1128,11 +1148,13 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
}
if (rows && rows[0] && rows[0].id) {
return callback(new Error(_('This address is already registered by someone else')));
}
module.exports.addConfirmation(list, emailNew, optInIp, {
action: 'update',
module.exports.addConfirmation(list, emailNew, ip, {
_action: 'update',
cid,
subscriber: old.id,
emailOld: old.email
@ -1142,3 +1164,114 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
});
});
};
module.exports.sendMail = (listId, email, template, subject, mailOpts, subscription, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
lists.get(listId, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
fields.list(list.id, (err, fieldList) => {
if (err) {
return callback(err);
}
let encryptionKeys = [];
fields.getRow(fieldList, subscription).forEach(field => {
if (field.type === 'gpg' && field.value) {
encryptionKeys.push(field.value.trim());
}
});
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl'], (err, configItems) => {
if (err) {
return callback(err);
}
const data = {
title: list.name,
contactAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
};
if (mailOpts.confirmUrlRoute) {
data.confirmUrl = urllib.resolve(configItems.serviceUrl, mailOpts.confirmUrlRoute + cid)
}
function sendMail(html, text) {
mailer.sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '),
address: email
},
subject: util.format(subject, list.name),
encryptionKeys
}, {
html,
text,
data
}, err => {
if (err) {
log.error('Subscription', err);
}
});
}
let text = {
template: 'subscription/mail-' + template + '-text.hbs'
};
let html = {
template: 'subscription/mail-' + template + '-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',
type: 'mjml'
};
helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => {
if (err) {
return sendMail(html, text);
}
sendMail(tmpl.html, tmpl.text);
});
return callback(null, cid);
});
});
});
});
};
/*
FIXME
function getUnsubscriptionMode = (listId, start, limit, callback) => {
listId = Number(listId) || 0;
if (!listId) {
return callback(new Error('Missing List ID'));
}
tableHelpers.list('subscription__' + listId, ['*'], 'email', null, start, limit, (err, rows, total) => {
if (!err) {
rows = rows.map(row => tools.convertKeys(row));
}
return callback(err, rows, total);
});
};
*/

View file

@ -43,7 +43,13 @@ function toDbKey(key) {
}
function fromDbKey(key) {
return key.replace(/[_\-]([a-z])/g, (m, c) => c.toUpperCase());
let prefix = '';
if (key.startsWith('_')) {
key = key.substring(1);
prefix = '_';
}
return prefix + key.replace(/[_\-]([a-z])/g, (m, c) => c.toUpperCase());
}
function convertKeys(obj, options) {
@ -54,7 +60,7 @@ function convertKeys(obj, options) {
if (options.skip && options.skip.indexOf(lKey) >= 0) {
return;
}
if (options.keep && options.skip.indexOf(lKey) < 0) {
if (options.keep && options.keep.indexOf(lKey) < 0) {
return;
}
response[lKey] = obj[key];

View file

@ -93,6 +93,8 @@ router.post('/subscribe/:listId', (req, res) => {
subscription.tz = (input.TIMEZONE || '').toString().trim();
}
subscription._action = 'subscribe';
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];

View file

@ -161,22 +161,32 @@ router.get('/:list/edit/:form', passport.csrfProtection, (req, res) => {
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'web_confirm_notice',
label: _('Web - Confirm Notice'),
name: 'web_confirm_subscription_notice',
label: _('Web - Confirm Subscription Notice'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_confirm_html',
name: 'mail_confirm_subscription_html',
label: _('Mail - Confirm Subscription (MJML)'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_confirm_text',
name: 'mail_confirm_subscription_text',
label: _('Mail - Confirm Subscription (Text)'),
type: 'text',
help: helpEmailText
}, {
name: 'web_subscribed',
name: 'mail_already_subscribed_html',
label: _('Mail - Already Subscribed (MJML)'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_already_subscribed_text',
label: _('Mail - Already Subscribed (Text)'),
type: 'text',
help: helpEmailText
}, {
name: 'web_subscribed_notice',
label: _('Web - Subscribed Notice'),
type: 'mjml',
help: helpMjmlGeneral
@ -217,18 +227,43 @@ router.get('/:list/edit/:form', passport.csrfProtection, (req, res) => {
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'web_unsubscribe_notice',
label: _('Web - Unsubscribe Notice'),
name: 'web_confirm_unsubscription_notice',
label: _('Web - Confirm Unsubscription Notice'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_unsubscribe_confirmed_html',
label: _('Mail - Unsubscribe Confirmed (MJML)'),
name: 'mail_confirm_unsubscription_html',
label: _('Mail - Confirm Unsubscription (MJML)'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_unsubscribe_confirmed_text',
label: _('Mail - Unsubscribe Confirmed (Text)'),
name: 'mail_confirm_unsubscription_text',
label: _('Mail - Confirm Unsubscription (Text)'),
type: 'text',
help: helpEmailText
}, {
name: 'mail_confirm_address_change_html',
label: _('Mail - Confirm Address Change (MJML)'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_confirm_address_change_text',
label: _('Mail - Confirm Address Change (Text)'),
type: 'text',
help: helpEmailText
}, {
name: 'web_unsubscribed_notice',
label: _('Web - Unsubscribed Notice'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_unsubscription_confirmed_html',
label: _('Mail - Unsubscription Confirmed (MJML)'),
type: 'mjml',
help: helpMjmlGeneral
}, {
name: 'mail_unsubscription_confirmed_text',
label: _('Mail - Unsubscription Confirmed (Text)'),
type: 'text',
help: helpEmailText
}]

View file

@ -71,6 +71,8 @@ router.get('/create', passport.csrfProtection, (req, res) => {
data.publicSubscribe = true;
}
data.unsubscriptionModeOptions = getUnsubscriptionModeOptions(data.unsubscriptionMode || lists.UnsubscriptionMode.ONE_STEP);
res.render('lists/create', data);
});
@ -103,6 +105,8 @@ router.get('/edit/:id', passport.csrfProtection, (req, res) => {
return row;
});
list.unsubscriptionModeOptions = getUnsubscriptionModeOptions(list.unsubscriptionMode);
list.csrfToken = req.csrfToken();
res.render('lists/edit', list);
});
@ -771,4 +775,28 @@ router.post('/quicklist/ajax', (req, res) => {
});
});
function getUnsubscriptionModeOptions(unsubscriptionMode) {
const options = [];
options[lists.UnsubscriptionMode.ONE_STEP] = {
value: lists.UnsubscriptionMode.ONE_STEP,
selected: unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP,
label: _('One-step (i.e. no email with confirmation link)')
};
options[lists.UnsubscriptionMode.TWO_STEP] = {
value: lists.UnsubscriptionMode.TWO_STEP,
selected: unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP,
label: _('Two-step (i.e. an email with confirmation link will be sent)')
};
options[lists.UnsubscriptionMode.MANUAL] = {
value: lists.UnsubscriptionMode.MANUAL,
selected: unsubscriptionMode === lists.UnsubscriptionMode.MANUAL,
label: _('Manual (i.e. unsubscription has to be performed by the list administrator)')
};
return options;
}
module.exports = router;

View file

@ -44,8 +44,8 @@ let corsOrCsrfProtection = (req, res, next) => {
}
};
router.get('/subscribe/:cid', (req, res, next) => {
subscriptions.subscribe(req.params.cid, req.ip, (err, subscription) => {
router.get('/confirm/:cid', (req, res, next) => {
subscriptions.processConfirmation(req.params.cid, req.ip, (err, subscription, action) => {
if (!err && !subscription) {
err = new Error(_('Selected subscription not found'));
err.status = 404;
@ -70,40 +70,9 @@ router.get('/subscribe/:cid', (req, res, next) => {
return next(err);
}
let data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
preferences: '/subscription/' + list.cid + '/manage/' + subscription.cid,
hasPubkey: !!configItems.pgpPrivateKey,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
template: {
template: 'subscription/web-subscribed.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
}
};
// FIXME - split decision based on action
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));
});
});
});
res.redirect('/subscription/' + list.cid + '/subscribed-notice');
if (configItems.disableConfirmations) {
return;
@ -318,164 +287,6 @@ router.get('/:cid/widget', cors(corsOptions), (req, res, next) => {
});
});
router.get('/:cid/confirm-notice', (req, res, next) => {
lists.getByCid(req.params.cid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
let data = {
title: list.name,
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));
});
});
});
});
});
});
router.get('/:cid/updated-notice', (req, res, next) => {
lists.getByCid(req.params.cid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
let data = {
title: list.name,
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));
});
});
});
});
});
});
router.get('/:cid/unsubscribe-notice', (req, res, next) => {
lists.getByCid(req.params.cid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
let data = {
title: list.name,
layout: 'subscription/layout',
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));
});
});
});
});
});
});
router.options('/:cid/subscribe', cors(corsOptions));
router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, res, next) => {
@ -496,62 +307,73 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
// Check if the subscriber seems legit. This is a really simple check, the only requirement is that
// the subsciber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this
// simple check should be replaced with an actual captcha
let subTime = Number(req.body.sub) || 0;
// allow clock skew 24h in the past and 24h to the future
let subTimeTest = !!(subTime > Date.now() - 24 * 3600 * 1000 && subTime < Date.now() + 24 * 3600 * 1000);
let addressTest = !req.body.address;
let testsPass = subTimeTest && addressTest;
lists.getByCid(req.params.cid, (err, list) => {
if (!err) {
if (!list) {
err = new Error(_('Selected list not found'));
err.status = 404;
} else if (!list.publicSubscribe) {
err = new Error(_('The list does not allow public subscriptions.'));
err.status = 403;
}
}
tools.validateEmail(email, false, err => {
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
if (req.xhr) {
return sendJsonError(err.message, 400);
}
req.flash('danger', err.message);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
let data = {};
Object.keys(req.body).forEach(key => {
if (key !== 'email' && key.charAt(0) !== '_') {
data[key] = (req.body[key] || '').toString().trim();
}
});
// Check if the subscriber seems legit. This is a really simple check, the only requirement is that
// the subsciber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this
// simple check should be replaced with an actual captcha
let subTime = Number(req.body.sub) || 0;
// allow clock skew 24h in the past and 24h to the future
let subTimeTest = !!(subTime > Date.now() - 24 * 3600 * 1000 && subTime < Date.now() + 24 * 3600 * 1000);
let addressTest = !req.body.address;
let testsPass = subTimeTest && addressTest;
data = tools.convertKeys(data);
data._address = req.body.address;
data._sub = req.body.sub;
data._skip = !testsPass;
subscriptions.addConfirmation(list, email, req.ip, data, (err, confirmCid) => {
if (!err && !confirmCid) {
err = new Error(_('Could not store confirmation data'));
lists.getByCid(req.params.cid, (err, list) => {
if (!err) {
if (!list) {
err = new Error(_('Selected list not found'));
err.status = 404;
} else if (!list.publicSubscribe) {
err = new Error(_('The list does not allow public subscriptions.'));
err.status = 403;
}
}
if (err) {
if (req.xhr) {
return sendJsonError(err);
}
req.flash('danger', err.message || err);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
return req.xhr ? sendJsonError(err) : next(err);
}
if (req.xhr) {
return res.status(200).json({
msg: _('Please Confirm Subscription')
});
}
res.redirect('/subscription/' + req.params.cid + '/confirm-notice');
let data = {};
Object.keys(req.body).forEach(key => {
if (key !== 'email' && key.charAt(0) !== '_') {
data[key] = (req.body[key] || '').toString().trim();
}
});
data = tools.convertKeys(data);
data._address = req.body.address;
data._sub = req.body.sub;
data._skip = !testsPass;
data._action = 'subscribe';
subscriptions.addConfirmation(list, email, req.ip, data, (err, confirmCid) => {
if (!err && !confirmCid) {
err = new Error(_('Could not store confirmation data'));
}
if (err) {
if (req.xhr) {
return sendJsonError(err);
}
req.flash('danger', err.message || err);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
if (req.xhr) {
return res.status(200).json({
msg: _('Please Confirm Subscription')
});
}
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
});
});
});
});
@ -732,7 +554,7 @@ router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection
return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/manage-address/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body));
}
req.flash('info', _('Email address updated, check your mailbox for verification instructions'));
req.flash('info', _('An email with further instructions has been sent to the provided address'));
res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid);
});
});
@ -822,7 +644,7 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (
log.error('Subscription', err);
return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/unsubscribe/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body));
}
res.redirect('/subscription/' + req.params.lcid + '/unsubscribe-notice');
res.redirect('/subscription/' + req.params.lcid + '/unsubscribed-notice');
fields.list(list.id, (err, fieldList) => {
if (err) {
@ -874,11 +696,11 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (
};
let text = {
template: 'subscription/mail-unsubscribe-confirmed-text.hbs'
template: 'subscription/mail-unsubscription-confirmed-text.hbs'
};
let html = {
template: 'subscription/mail-unsubscribe-confirmed-html.mjml.hbs',
template: 'subscription/mail-unsubscription-confirmed-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',
type: 'mjml'
};
@ -896,6 +718,26 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (
});
});
router.get('/:cid/confirm-subscription-notice', (req, res, next) => {
notice('confirm-subscription', req, res, next);
});
router.get('/:cid/confirm-unsubscription-notice', (req, res, next) => {
notice('confirm-unsubscription', req, res, next);
});
router.get('/:cid/subscribed-notice', (req, res, next) => {
notice('subscribed', req, res, next);
});
router.get('/:cid/updated-notice', (req, res, next) => {
notice('updated', req, res, next);
});
router.get('/:cid/unsubscribed-notice', (req, res, next) => {
notice('unsubscribed', req, res, next);
});
router.post('/publickey', passport.parseForm, (req, res, next) => {
settings.list(['pgpPassphrase', 'pgpPrivateKey'], (err, configItems) => {
if (err) {
@ -934,4 +776,59 @@ router.post('/publickey', passport.parseForm, (req, res, next) => {
});
});
function notice(type, req, res, next) {
lists.getByCid(req.params.cid, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
}
if (err) {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => {
if (err) {
return next(err);
}
let data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
template: {
template: 'subscription/web-' + type + '-notice.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
}
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-' + type + '-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));
});
});
});
});
});
}
module.exports = router;

View file

@ -0,0 +1,24 @@
# Header section
# Define incrementing schema version number
SET @schema_version = '28';
# Add unsubscription mode field to lists
ALTER TABLE `lists` ADD COLUMN `unsubscription_mode` int(11) unsigned DEFAULT 0 NOT NULL AFTER `public_subscribe`;
# Change the name of the column to better reflect that confirmations are also used for unsubscription and email address update
ALTER TABLE `confirmations` CHANGE `opt_in_ip` `ip` varchar(100) DEFAULT NULL;
# Rename affected forms in custom_forms_data
update custom_forms_data set data_key="mail_confirm_subscription_html" where data_key="mail_confirm_html";
update custom_forms_data set data_key="mail_confirm_subscription_text" where data_key="mail_confirm_text";
update custom_forms_data set data_key="mail_unsubscription_confirmed_html" where data_key="mail_unsubscribe_confirmed_html";
update custom_forms_data set data_key="mail_unsubscription_confirmed_text" where data_key="mail_unsubscribe_confirmed_text";
update custom_forms_data set data_key="web_confirm_subscription_notice" where data_key="web_confirm_notice";
update custom_forms_data set data_key="web_subscribed_notice" where data_key="web_subscribed";
update custom_forms_data set data_key="web_unsubscribed_notice" where data_key="web_unsubscribe_notice";
# Footer section
LOCK TABLES `settings` WRITE;
INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version;
UNLOCK TABLES;

View file

@ -26,11 +26,27 @@
<hr />
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="public_subscribe" value="1" {{#if publicSubscribe}} checked {{/if}}> {{#translate}}Allow public users to subscribe themselves{{/translate}}
</label>
<div class="form-group">
<label for="default_form" class="col-sm-2 control-label">{{#translate}}Subscription{{/translate}}</label>
<div class="col-sm-10">
<div class="checkbox">
<label>
<input type="checkbox" name="public_subscribe" value="1" {{#if publicSubscribe}} checked {{/if}}> {{#translate}}Allow public users to subscribe themselves{{/translate}}
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="default_form" class="col-sm-2 control-label">{{#translate}}Unsubscription{{/translate}}</label>
<div class="col-sm-10">
<select class="form-control" id="unsubscription_mode" name="unsubscription_mode">
{{#each unsubscriptionModeOptions}}
<option value="{{value}}"{{#if selected}} selected{{/if}}>{{label}}</option>
{{/each}}
</select>
<span class="help-block">{{#translate}}Select how an unsuscription request by subscriber is handled.{{/translate}}</span>
</div>
</div>

View file

@ -56,11 +56,27 @@
<hr />
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="public_subscribe" value="1" {{#if publicSubscribe}} checked {{/if}}> {{#translate}}Allow public users to subscribe themselves{{/translate}}
</label>
<div class="form-group">
<label for="default_form" class="col-sm-2 control-label">{{#translate}}Subscription{{/translate}}</label>
<div class="col-sm-10">
<div class="checkbox">
<label>
<input type="checkbox" name="public_subscribe" value="1" {{#if publicSubscribe}} checked {{/if}}> {{#translate}}Allow public users to subscribe themselves{{/translate}}
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="default_form" class="col-sm-2 control-label">{{#translate}}Unsubscription{{/translate}}</label>
<div class="col-sm-10">
<select class="form-control" id="unsubscription_mode" name="unsubscription_mode">
{{#each unsubscriptionModeOptions}}
<option value="{{value}}"{{#if selected}} selected{{/if}}>{{label}}</option>
{{/each}}
</select>
<span class="help-block">{{#translate}}Select how an unsuscription request by subscriber is handled.{{/translate}}</span>
</div>
</div>

View file

@ -46,11 +46,15 @@
<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}}/confirm-subscription-notice?fid={{form.id}}" target="_blank">{{#translate}}Confirm Subscription Notice{{/translate}}</a>
|
<a href="/subscription/{{list.cid}}/confirm-unsubscription-notice?fid={{form.id}}" target="_blank">{{#translate}}Confirm Unsubscription Notice{{/translate}}</a>
|
<a href="/subscription/{{list.cid}}/subscribed-notice?fid={{form.id}}" target="_blank">{{#translate}}Subscribed 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>
<a href="/subscription/{{list.cid}}/unsubscribed-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>

View file

@ -68,7 +68,7 @@
<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}}" />
<input type="hidden" name="customFormChangeOnly" value="1" />
<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>

View file

@ -0,0 +1,24 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Email address already subscribed{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}We have received a subscription request. Your email address is however already registered.{{/translate}}.
</mj-text>
<mj-text mj-class="p">
{{#translate}}If you received this email by mistake, simply delete it. Your existing subscription won't be affected.{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#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,18 @@
{{{title}}}
{{#translate}}Email address already subscribed{{/translate}}
================================
{{#translate}}We have received a subscription request. Your email address is however already registered.{{/translate}}
{{#translate}}If you received this email by mistake, simply delete it. Your existing subscription won't be affected.{{/translate}}
{{#translate}}If you want to modify your subscription then you can:{{/translate}}
{{#translate}}manage your preferences{{/translate}}: {{preferencesUrl}}
- {{#translate}}or{{/translate}} -
{{#translate}}unsubscribe here{{/translate}}: {{unsubscribeUrl}}
{{#translate}}For questions about this list, please contact:{{/translate}}
{{{contactAddress}}}

View file

@ -0,0 +1,17 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Please Confirm Subscription Address Change{{/translate}}
</mj-text>
<mj-button mj-class="button" href="{{confirmUrl}}">
{{#translate}}Yes, subscribe this email address to the 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,10 @@
{{{title}}}
{{#translate}}Please Confirm Subscription Address Change{{/translate}}
==========================================
{{#translate}}Yes, subscribe this email address to the list{{/translate}}: {{{confirmUrl}}}
{{#translate}}If you received this email by mistake, simply delete it. You won't be subscribed unless you click the confirmation link above.{{/translate}}
{{#translate}}For questions about this list, please contact:{{/translate}}
{{{contactAddress}}}

View file

@ -0,0 +1,17 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Please Confirm Unsubscription{{/translate}}
</mj-text>
<mj-button mj-class="button" href="{{confirmUrl}}">
{{#translate}}Yes, unsubscribe me from 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 unsubscribed 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,10 @@
{{{title}}}
{{#translate}}Please Confirm Subscription{{/translate}}
===========================
{{#translate}}Yes, unsubscribe me from this list{{/translate}}: {{{confirmUrl}}}
{{#translate}}If you received this email by mistake, simply delete it. You won't be unsubscribed unless you click the confirmation link above.{{/translate}}
{{#translate}}For questions about this list, please contact:{{/translate}}
{{{contactAddress}}}

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