Merge with upstream

This commit is contained in:
vladimir 2017-05-27 14:07:56 +02:00
commit 25bb4afa80
60 changed files with 2177 additions and 1215 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
/.idea
/last-failed-e2e-test.*
node_modules
npm-debug.log
.DS_Store

5
app.js
View file

@ -184,8 +184,9 @@ app.use((req, res, next) => {
res.locals.customScripts = config.customscripts || [];
let bodyClasses = [];
app.get('env') === 'test' && bodyClasses.push('page--' + (req.path.substring(1).replace(/\//g, '--') || 'home'));
req.user && bodyClasses.push('logged-in user-' + req.user.username);
if (req.user) {
bodyClasses.push('logged-in user-' + req.user.username);
}
res.locals.bodyClass = bodyClasses.join(' ');
settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => {

View file

@ -22,7 +22,8 @@ module.exports = {
injectCustomFormData,
injectCustomFormTemplates,
filterCustomFields,
getMjmlTemplate
getMjmlTemplate,
rollbackAndReleaseConnection
};
function getDefaultMergeTags(callback) {
@ -124,7 +125,7 @@ function filterCustomFields(customFieldsIn = [], fieldIds = [], method = 'includ
id: 'email',
name: 'Email Address',
type: 'Email',
typeSubsciptionEmail: true
typeSubscriptionEmail: true
}, {
id: 'firstname',
name: 'First Name',
@ -285,3 +286,10 @@ function captureFlashMessages(req, res, callback) {
callback(null, flash);
});
}
function rollbackAndReleaseConnection(connection, callback) {
connection.rollback(() => {
connection.release();
return callback();
});
}

View file

@ -1056,13 +1056,13 @@ module.exports.updateMessage = (message, status, updateSubscription, callback) =
let statusCode;
if (status === 'unsubscribed') {
statusCode = 2;
}
if (status === 'bounced') {
statusCode = 3;
}
if (status === 'complained') {
statusCode = 4;
statusCode = subscriptions.Status.UNSUBSCRIBED;
} else if (status === 'bounced') {
statusCode = subscriptions.Status.BOUNCED;
} else if (status === 'complained') {
statusCode = subscriptions.Status.COMPLAINED;
} else {
return callback(new Error(_('Unrecognized message status')));
}
let query = 'UPDATE `campaigns` SET `' + status + '`=`' + status + '`+1 WHERE id=? LIMIT 1';
@ -1076,7 +1076,7 @@ module.exports.updateMessage = (message, status, updateSubscription, callback) =
}
if (updateSubscription) {
subscriptions.changeStatus(message.subscription, message.list, statusCode === 2 ? message.campaign : false, statusCode, callback);
subscriptions.changeStatus(message.list, message.subscription, statusCode === subscriptions.Status.UNSUBSCRIBED ? message.campaign : false, statusCode, callback);
} else {
return callback(null, true);
}

View file

@ -0,0 +1,91 @@
'use strict';
let db = require('../db');
let shortid = require('shortid');
let helpers = require('../helpers');
let _ = require('../translate')._;
/*
Adds new entry to the confirmations tables. Generates confirmation cid, which it returns.
*/
module.exports.addConfirmation = (listId, action, ip, data, callback) => {
let cid = shortid.generate();
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'INSERT INTO confirmations (cid, list, action, ip, data) VALUES (?,?,?,?,?)';
connection.query(query, [cid, listId, action, ip, JSON.stringify(data || {})], (err, result) => {
connection.release();
if (err) {
return callback(err);
}
if (!result || !result.affectedRows) {
return callback(new Error(_('Could not store confirmation data')));
}
return callback(null, cid);
});
});
};
/*
Atomically retrieves confirmation from the database, removes it from the database and returns it.
*/
module.exports.takeConfirmation = (cid, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.beginTransaction(err => {
if (err) {
connection.release();
return callback(err);
}
let query = 'SELECT cid, list, action, ip, data FROM confirmations WHERE cid=? LIMIT 1';
connection.query(query, [cid], (err, rows) => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (!rows || !rows.length) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(null, false));
}
connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
let data;
try {
data = JSON.parse(rows[0].data);
} catch (E) {
data = {};
}
const result = {
listId: rows[0].list,
action: rows[0].action,
ip: rows[0].ip,
data
};
return callback(null, result);
});
});
});
});
});
};

View file

@ -14,22 +14,31 @@ 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',
'web_manual_unsubscribe_notice'
];
module.exports.list = (listId, callback) => {
listId = Number(listId) || 0;

View file

@ -7,7 +7,18 @@ let segments = require('./segments');
let _ = require('../translate')._;
let tableHelpers = require('../table-helpers');
let allowedKeys = ['description', 'default_form', 'public_subscribe'];
const UnsubscriptionMode = {
ONE_STEP: 0,
ONE_STEP_WITH_FORM: 1,
TWO_STEP: 2,
TWO_STEP_WITH_FORM: 3,
MANUAL: 4,
MAX: 5
};
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 +110,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 +225,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

@ -13,7 +13,8 @@ const ReportState = {
SCHEDULED: 0,
PROCESSING: 1,
FINISHED: 2,
FAILED: 3
FAILED: 3,
MAX: 4
};
module.exports.ReportState = ReportState;
@ -245,8 +246,8 @@ module.exports.getCampaignResults = (campaign, select, clause, callback) => {
const query = 'SELECT ' + selFields.join(', ') + ' FROM `subscription__' + campaign.list + '` subscribers INNER JOIN `campaign__' + campaign.id + '` campaign on subscribers.id=campaign.subscription LEFT JOIN `campaign_tracker__' + campaign.id + '` tracker on subscribers.id=tracker.subscriber ' + clause;
connection.query(query, (err, results) => {
if (err) {
connection.release();
if (err) {
return callback(err);
}

View file

@ -15,6 +15,8 @@ function listValues(filter, callback) {
filter = false;
}
// TODO: It would be good to cache the settings. It feels awkward to always go to DB to retrieve something what is essentially a constant
filter = [].concat(filter || []).map(key => tools.toDbKey(key));
db.getConnection((err, connection) => {

View file

@ -5,16 +5,20 @@ 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');
let settings = require('./settings');
let mailer = require('../mailer');
let urllib = require('url');
let log = require('npmlog');
let _ = require('../translate')._;
let util = require('util');
let tableHelpers = require('../table-helpers');
const Status = {
SUBSCRIBED: 1,
UNSUBSCRIBED: 2,
BOUNCED: 3,
COMPLAINED: 4,
MAX: 5
};
module.exports.Status = Status;
module.exports.list = (listId, start, limit, callback) => {
listId = Number(listId) || 0;
if (!listId) {
@ -88,197 +92,17 @@ module.exports.filter = (listId, request, columns, segmentId, callback) => {
};
module.exports.addConfirmation = (list, email, optInIp, data, callback) => {
let cid = shortid.generate();
tools.validateEmail(email, false, err => {
if (err) {
return callback(err);
}
db.getConnection((err, connection) => {
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 (err) {
return callback(err);
}
if (!result || !result.affectedRows) {
return callback(null, false);
}
fields.list(list.id, (err, fieldList) => {
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;
}
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);
});
});
return callback(null, cid);
});
});
});
});
});
};
module.exports.subscribe = (cid, optInIp, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM confirmations WHERE cid=? LIMIT 1';
connection.query(query, [cid], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
let subscription;
let listId = rows[0].list;
let email = rows[0].email;
try {
subscription = JSON.parse(rows[0].data);
} catch (E) {
subscription = {};
}
if (subscription.action === 'update' && subscription.subscriber) {
// update email address instead of adding new
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'UPDATE `subscription__' + listId + '` SET `email`=? WHERE `id`=? LIMIT 1';
let args = [email, subscription.subscriber];
connection.query(query, args, err => {
if (err) {
connection.release();
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, subscription.subscriber, callback);
});
});
});
return;
}
subscription.cid = cid;
subscription.list = listId;
subscription.email = email;
let optInCountry = geoip.lookupCountry(optInIp) || null;
module.exports.insert(listId, {
email,
cid,
optInIp,
optInCountry,
status: 1
}, subscription, (err, result) => {
if (err) {
return callback(err);
}
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, callback);
});
});
});
});
});
};
module.exports.insert = (listId, meta, subscription, callback) => {
/*
Adds a new subscription. Returns error if a subscription with the same email address is already present and is not unsubscribed.
If it is unsubscribed, the existing subscription is changed based on the provided data.
If meta.partial is true, it updates even an active subscription.
*/
module.exports.insert = (listId, meta, subscriptionData, callback) => {
meta = tools.convertKeys(meta);
subscription = tools.convertKeys(subscription);
subscriptionData = tools.convertKeys(subscriptionData);
meta.email = meta.email || subscription.email;
meta.email = meta.email || subscriptionData.email;
meta.cid = meta.cid || shortid.generate();
fields.list(listId, (err, fieldList) => {
@ -292,8 +116,8 @@ module.exports.insert = (listId, meta, subscription, callback) => {
let values = [];
let allowedKeys = ['first_name', 'last_name', 'tz', 'is_test'];
Object.keys(subscription).forEach(key => {
let value = subscription[key];
Object.keys(subscriptionData).forEach(key => {
let value = subscriptionData[key];
key = tools.toDbKey(key);
if (key === 'tz') {
value = (value || '').toString().toLowerCase().trim();
@ -307,7 +131,7 @@ module.exports.insert = (listId, meta, subscription, callback) => {
}
});
fields.getValues(fields.getRow(fieldList, subscription, true, true, !!meta.partial), true).forEach(field => {
fields.getValues(fields.getRow(fieldList, subscriptionData, true, true, !!meta.partial), true).forEach(field => {
keys.push(field.key);
values.push(field.value);
});
@ -326,10 +150,7 @@ module.exports.insert = (listId, meta, subscription, callback) => {
let query = 'SELECT `id`, `status`, `cid` FROM `subscription__' + listId + '` WHERE `email`=? OR `cid`=? LIMIT 1';
connection.query(query, [meta.email, meta.cid], (err, rows) => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
let query;
@ -338,25 +159,26 @@ module.exports.insert = (listId, meta, subscription, callback) => {
let entryId = existing ? existing.id : false;
meta.cid = existing ? rows[0].cid : meta.cid;
meta.status = meta.status || (existing ? existing.status : 1);
meta.status = meta.status || (existing ? existing.status : Status.SUBSCRIBED);
let statusChange = !existing || existing.status !== meta.status;
let statusDirection;
if (existing && existing.status === Status.SUBSCRIBED && !meta.partial) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Email address already registered'))));
}
if (statusChange) {
keys.push('status', 'status_change');
values.push(meta.status, new Date());
statusDirection = !existing ? (meta.status === 1 ? '+' : false) : (existing.status === 1 ? '-' : '+');
statusDirection = !existing ? (meta.status === Status.SUBSCRIBED ? '+' : false) : (existing.status === Status.SUBSCRIBED ? '-' : '+');
}
if (!keys.length) {
// nothing to update
return connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, {
@ -380,10 +202,7 @@ module.exports.insert = (listId, meta, subscription, callback) => {
connection.query(query, queryArgs, (err, result) => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
entryId = result.insertId || entryId;
@ -391,17 +210,11 @@ module.exports.insert = (listId, meta, subscription, callback) => {
if (statusChange && statusDirection) {
connection.query('UPDATE lists SET `subscribers`=`subscribers`' + statusDirection + '1 WHERE id=?', [listId], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, {
@ -414,10 +227,7 @@ module.exports.insert = (listId, meta, subscription, callback) => {
} else {
connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, {
@ -575,7 +385,7 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => {
}
if (!cid) {
return callback(new Error(_('Missing subscription ID')));
return callback(new Error(_('Missing Subscription ID')));
}
fields.list(listId, (err, fieldList) => {
@ -627,46 +437,7 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => {
});
};
module.exports.unsubscribe = (listId, email, campaignId, callback) => {
listId = Number(listId) || 0;
email = (email || '').toString().trim();
campaignId = (campaignId || '').toString().trim() || false;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
if (!email) {
return callback(new Error(_('Missing email address')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM `subscription__' + listId + '` WHERE `email`=?', [email], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length || rows[0].status !== 1) {
return callback(null, false);
}
let subscription = tools.convertKeys(rows[0]);
module.exports.changeStatus(subscription.id, listId, campaignId, 2, err => {
if (err) {
return callback(err);
}
return callback(null, subscription);
});
});
});
};
module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
module.exports.changeStatus = (listId, id, campaignId, status, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
@ -679,17 +450,11 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
connection.query('SELECT `status` FROM `subscription__' + listId + '` WHERE id=? LIMIT 1', [id], (err, rows) => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (!rows || !rows.length) {
return connection.rollback(() => {
connection.release();
return callback(null, false);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(null, false));
}
let oldStatus = rows[0].status;
@ -697,31 +462,22 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
let statusDirection;
if (!statusChange) {
return connection.rollback(() => {
connection.release();
return callback(null, true);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(null, true));
}
if (statusChange && oldStatus === 1 || status === 1) {
statusDirection = status === 1 ? '+' : '-';
if (statusChange && oldStatus === Status.SUBSCRIBED || status === Status.SUBSCRIBED) {
statusDirection = status === Status.SUBSCRIBED ? '+' : '-';
}
connection.query('UPDATE `subscription__' + listId + '` SET `status`=?, `status_change`=NOW() WHERE id=? LIMIT 1', [status, id], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (!statusDirection) {
return connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, true);
@ -730,20 +486,14 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
connection.query('UPDATE `lists` SET `subscribers`=`subscribers`' + statusDirection + '1 WHERE id=? LIMIT 1', [listId], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
// status change is not related to a campaign or it marks message as bounced etc.
if (!campaignId || status > 2) {
if (!campaignId || status !== Status.SUBSCRIBED) {
return connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, true);
@ -752,10 +502,7 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
connection.query('SELECT `id` FROM `campaigns` WHERE `cid`=? LIMIT 1', [campaignId], (err, rows) => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
let campaign = rows && rows[0] || false;
@ -764,10 +511,7 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
// should not happend
return connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, true);
@ -775,12 +519,9 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
}
// we should see only unsubscribe events here but you never know
connection.query('UPDATE `campaigns` SET `unsubscribed`=`unsubscribed`' + (status === 2 ? '+' : '-') + '1 WHERE `cid`=? LIMIT 1', [campaignId], err => {
connection.query('UPDATE `campaigns` SET `unsubscribed`=`unsubscribed`' + (status === Status.UNSUBSCRIBED ? '+' : '-') + '1 WHERE `cid`=? LIMIT 1', [campaignId], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
let query = 'UPDATE `campaign__' + campaign.id + '` SET `status`=? WHERE `list`=? AND `subscription`=? LIMIT 1';
@ -789,18 +530,12 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
// Updated tracker status
connection.query(query, values, err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
return connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, true);
@ -852,19 +587,13 @@ module.exports.delete = (listId, cid, callback) => {
connection.query('DELETE FROM `subscription__' + listId + '` WHERE cid=? LIMIT 1', [cid], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (subscription.status !== 1) {
if (subscription.status !== Status.SUBSCRIBED) {
return connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, subscription.email);
@ -873,17 +602,11 @@ module.exports.delete = (listId, cid, callback) => {
connection.query('UPDATE lists SET subscribers=subscribers-1 WHERE id=? LIMIT 1', [listId], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, subscription.email);
@ -963,11 +686,10 @@ module.exports.updateImport = (listId, importId, data, callback) => {
connection.release();
return callback(null, affected);
});
return;
}
} else {
connection.release();
return callback(null, affected);
}
});
});
};
@ -1075,13 +797,13 @@ module.exports.listImports = (listId, callback) => {
});
};
module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
updates = tools.convertKeys(updates);
/*
Performs checks before update of an address. This includes finding the existing subscriber, validating the new email
and checking whether the new email does not conflict with other subscribers.
*/
module.exports.updateAddressCheck = (list, cid, emailNew, ip, callback) => {
cid = (cid || '').toString().trim();
let emailNew = (updates.emailNew || '').toString().trim();
if (!list || !list.id) {
return callback(new Error(_('Missing List ID')));
}
@ -1100,7 +822,7 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
return callback(err);
}
let query = 'SELECT `id`, `email` FROM `subscription__' + list.id + '` WHERE `cid`=? LIMIT 1';
let query = 'SELECT * FROM `subscription__' + list.id + '` WHERE `cid`=? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1';
let args = [cid];
connection.query(query, args, (err, rows) => {
if (err) {
@ -1119,7 +841,7 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
let old = rows[0];
let query = 'SELECT `id` FROM `subscription__' + list.id + '` WHERE `email`=? AND `cid`<>? LIMIT 1';
let query = 'SELECT `id` FROM `subscription__' + list.id + '` WHERE `email`=? AND `cid`<>? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1';
let args = [emailNew, cid];
connection.query(query, args, (err, rows) => {
connection.release();
@ -1127,18 +849,77 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
return callback(err);
}
if (rows && rows[0] && rows[0].id) {
return callback(new Error(_('This address is already registered by someone else')));
if (rows && rows.length > 0) {
return callback(null, old, false);
} else {
return callback(null, old, true);
}
module.exports.addConfirmation(list, emailNew, optInIp, {
action: 'update',
cid,
subscriber: old.id,
emailOld: old.email
}, callback);
});
});
});
});
};
/*
Updates address in subscription__xxx
*/
module.exports.updateAddress = (listId, subscriptionId, emailNew, callback) => {
// update email address instead of adding new
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.beginTransaction(err => {
if (err) {
connection.release();
return callback(err);
}
let query = 'SELECT `id` FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1';
let args = [emailNew, subscriptionId];
connection.query(query, args, (err, rows) => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (rows && rows.length > 0) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Email address already registered'))));
}
let query = 'DELETE FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>?';
let args = [emailNew, subscriptionId];
connection.query(query, args, err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
let query = 'UPDATE `subscription__' + listId + '` SET `email`=? WHERE `id`=? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1';
let args = [emailNew, subscriptionId];
connection.query(query, args, (err, result) => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (!result || !result.affectedRows) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Subscription not found in this list'))));
}
return connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback();
});
});
});
});
});
});
};
module.exports.getUnsubscriptionMode = (list, subscriptionId) => list.unsubscriptionMode; // eslint-disable-line no-unused-vars
// TODO: Once the unsubscription mode is customizable per segment, then this will be a good place to process it.

View file

@ -0,0 +1,157 @@
'use strict';
const log = require('npmlog');
let fields = require('./models/fields');
let settings = require('./models/settings');
let mailer = require('./mailer');
let urllib = require('url');
let helpers = require('./helpers');
let _ = require('./translate')._;
let util = require('util');
module.exports = {
sendAlreadySubscribed,
sendConfirmAddressChange,
sendConfirmSubscription,
sendConfirmUnsubscription,
sendSubscriptionConfirmed,
sendUnsubscriptionConfirmed
};
function sendSubscriptionConfirmed(list, email, subscription, callback) {
const relativeUrls = {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid,
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
};
sendMail(list, email, 'subscription-confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, subscription, callback);
}
function sendAlreadySubscribed(list, email, subscription, callback) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid,
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
};
sendMail(list, email, 'already-subscribed', _('%s: Email Address Already Registered'), relativeUrls, mailOpts, subscription, callback);
}
function sendConfirmAddressChange(list, email, cid, subscription, callback) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
confirmUrl: '/subscription/confirm/change-address/' + cid
};
sendMail(list, email, 'confirm-address-change', _('%s: Please Confirm Email Change in Subscription'), relativeUrls, mailOpts, subscription, callback);
}
function sendConfirmSubscription(list, email, cid, subscription, callback) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
confirmUrl: '/subscription/confirm/subscribe/' + cid
};
sendMail(list, email, 'confirm-subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, subscription, callback);
}
function sendConfirmUnsubscription(list, email, cid, subscription, callback) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
confirmUrl: '/subscription/confirm/unsubscribe/' + cid
};
sendMail(list, email, 'confirm-unsubscription', _('%s: Please Confirm Unsubscription'), relativeUrls, mailOpts, subscription, callback);
}
function sendUnsubscriptionConfirmed(list, email, subscription, callback) {
const relativeUrls = {
subscribeUrl: '/subscription/' + list.cid + '?cid=' + subscription.cid
};
sendMail(list, email, 'unsubscription-confirmed', _('%s: Unsubscription Confirmed'), relativeUrls, {}, subscription, callback);
}
function sendMail(list, email, template, subject, relativeUrls, mailOpts, subscription, callback) {
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', 'disableConfirmations'], (err, configItems) => {
if (err) {
return callback(err);
}
if (!mailOpts.ignoreDisableConfirmations && configItems.disableConfirmations) {
return;
}
const data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
contactAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress
};
for (let relativeUrlKey in relativeUrls) {
data[relativeUrlKey] = urllib.resolve(configItems.serviceUrl, relativeUrls[relativeUrlKey]);
}
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();
});
});
}

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];
@ -142,7 +148,6 @@ function updateMenu(res) {
}
function validateEmail(address, checkBlocked, callback) {
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
if (checkBlocked && blockedUsers.indexOf(user) >= 0) {
return callback(new Error(util.format(_('Blocked email address "%s"'), address)));
@ -175,7 +180,7 @@ function validateEmail(address, checkBlocked, callback) {
function getMessageLinks(serviceUrl, campaign, list, subscription) {
return {
LINK_UNSUBSCRIBE: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?auto=yes&c=' + campaign.cid),
LINK_UNSUBSCRIBE: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?c=' + campaign.cid),
LINK_PREFERENCES: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid),
LINK_BROWSER: urllib.resolve(serviceUrl, '/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid),
CAMPAIGN_ID: campaign.cid,

View file

@ -1,3 +1,3 @@
{
"schemaVersion": 28
"schemaVersion": 29
}

View file

@ -17,7 +17,7 @@
"sqldumptest": "NODE_ENV=test DUMP_NAME_SUFFIX=-test npm run sqldump",
"sqlresettest": "NODE_ENV=test npm run sqldrop && NODE_ENV=test npm run sqlinit",
"starttest": "NODE_ENV=test node index.js",
"_e2e": "NODE_ENV=test mocha test/e2e/index.js",
"_e2e": "NODE_ENV=test node test/e2e/index.js",
"e2e": "npm run sqlresettest && npm run _e2e"
},
"repository": {
@ -41,7 +41,8 @@
"jsxgettext-andris": "^0.9.0-patch.1",
"mocha": "^3.3.0",
"phantomjs": "^2.1.7",
"selenium-webdriver": "^3.4.0"
"selenium-webdriver": "^3.4.0",
"url-pattern": "^1.0.3"
},
"optionalDependencies": {
"posix": "^4.1.1"
@ -69,6 +70,7 @@
"faker": "^4.1.0",
"feedparser": "^2.1.0",
"file-type": "^4.1.0",
"fs-extra": "^3.0.1",
"geoip-ultralight": "^0.1.5",
"gettext-parser": "^1.2.2",
"gm": "^1.23.0",

View file

@ -5,10 +5,12 @@ let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields');
let blacklist = require('../lib/models/blacklist');
let subscriptions = require('../lib/models/subscriptions');
let confirmations = require('../lib/models/confirmations');
let tools = require('../lib/tools');
let express = require('express');
let log = require('npmlog');
let router = new express.Router();
let mailHelpers = require('../lib/subscription-mail-helpers');
router.all('/*', (req, res, next) => {
if (!req.query.access_token) {
@ -123,7 +125,7 @@ router.post('/subscribe/:listId', (req, res) => {
}
if (/^(yes|true|1)$/i.test(input.REQUIRE_CONFIRMATION)) {
subscriptions.addConfirmation(list, input.EMAIL, req.ip, subscription, (err, cid) => {
confirmations.addConfirmation(list.id, 'subscribe', req.ip, subscription, (err, confirmCid) => {
if (err) {
log.error('API', err);
res.status(500);
@ -132,13 +134,25 @@ router.post('/subscribe/:listId', (req, res) => {
data: []
});
}
mailHelpers.sendConfirmSubscription(list, input.EMAIL, confirmCid, subscription, (err) => {
if (err) {
log.error('API', err);
res.status(500);
return res.json({
error: err.message || err,
data: []
});
}
res.status(200);
res.json({
data: {
id: cid
id: confirmCid
}
});
});
});
} else {
subscriptions.insert(list.id, meta, subscription, (err, response) => {
if (err) {
@ -189,7 +203,25 @@ router.post('/unsubscribe/:listId', (req, res) => {
data: []
});
}
subscriptions.unsubscribe(list.id, input.EMAIL, false, (err, subscription) => {
subscriptions.getByEmail(list.id, input.EMAIL, (err, subscription) => {
if (err) {
res.status(500);
return res.json({
error: err.message || err,
data: []
});
}
if (!subscription) {
res.status(404);
return res.json({
error: 'Subscription with given email not found',
data: []
});
}
subscriptions.changeStatus(list.id, subscription.id, false, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
res.status(500);
return res.json({
@ -206,6 +238,7 @@ router.post('/unsubscribe/:listId', (req, res) => {
});
});
});
});
});
router.post('/delete/:listId', (req, res) => {

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,20 +227,50 @@ 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
}, {
name: 'web_manual_unsubscribe_notice',
label: _('Web - Manual Unsubscribe Notice'),
type: 'mjml',
help: helpMjmlGeneral
}]
}
];

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);
});
@ -447,7 +451,7 @@ router.post('/subscription/unsubscribe', passport.parseForm, passport.csrfProtec
return res.redirect('/lists/view/' + list.id);
}
subscriptions.unsubscribe(list.id, subscription.email, false, err => {
subscriptions.changeStatus(list.id, subscription.id, false, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
req.flash('danger', err && err.message || err || _('Could not unsubscribe user'));
return res.redirect('/lists/subscription/' + list.id + '/edit/' + subscription.cid);
@ -771,4 +775,40 @@ 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.ONE_STEP_WITH_FORM] = {
value: lists.UnsubscriptionMode.ONE_STEP_WITH_FORM,
selected: unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM,
label: _('One-step with unsubscription form (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.TWO_STEP_WITH_FORM] = {
value: lists.UnsubscriptionMode.TWO_STEP_WITH_FORM,
selected: unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM,
label: _('Two-step with unsubscription form (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

@ -4,10 +4,8 @@ let log = require('npmlog');
let config = require('config');
let tools = require('../lib/tools');
let helpers = require('../lib/helpers');
let mailer = require('../lib/mailer');
let passport = require('../lib/passport');
let express = require('express');
let urllib = require('url');
let router = new express.Router();
let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields');
@ -18,6 +16,9 @@ let _ = require('../lib/translate')._;
let util = require('util');
let cors = require('cors');
let cache = require('memory-cache');
let geoip = require('geoip-ultralight');
let confirmations = require('../lib/models/confirmations');
let mailHelpers = require('../lib/subscription-mail-helpers');
let originWhitelist = config.cors && config.cors.origins || [];
@ -44,10 +45,10 @@ let corsOrCsrfProtection = (req, res, next) => {
}
};
router.get('/subscribe/:cid', (req, res, next) => {
subscriptions.subscribe(req.params.cid, req.ip, (err, subscription) => {
if (!err && !subscription) {
err = new Error(_('Selected subscription not found'));
function checkAndExecuteConfirmation(req, action, errorMsg, next, exec) {
confirmations.takeConfirmation(req.params.cid, (err, confirmation) => {
if (!err && (!confirmation || confirmation.action !== action)) {
err = new Error(_(errorMsg));
err.status = 404;
}
@ -55,7 +56,7 @@ router.get('/subscribe/:cid', (req, res, next) => {
return next(err);
}
lists.get(subscription.list, (err, list) => {
lists.get(confirmation.listId, (err, list) => {
if (!err && !list) {
err = new Error(_('Selected list not found'));
err.status = 404;
@ -65,109 +66,102 @@ router.get('/subscribe/:cid', (req, res, next) => {
return next(err);
}
settings.list(['defaultHomepage', 'serviceUrl', 'pgpPrivateKey', 'defaultAddress', 'defaultPostaddress', 'defaultFrom', 'disableConfirmations'], (err, configItems) => {
exec(confirmation, list);
});
});
}
router.get('/confirm/subscribe/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'subscribe', 'Request invalid or already completed. If your subscription request is still pending, please subscribe again.', next, (confirmation, list) => {
const data = confirmation.data;
let optInCountry = geoip.lookupCountry(confirmation.ip) || null;
const meta = {
email: data.email,
optInIp: confirmation.ip,
optInCountry,
status: subscriptions.Status.SUBSCRIBED
};
subscriptions.insert(list.id, meta, data.subscriptionData, (err, result) => {
if (err) {
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'
if (!result.entryId) {
return next(new Error(_('Could not save subscription')));
}
};
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribed', data, (err, data) => {
subscriptions.getById(list.id, result.entryId, (err, subscription) => {
if (err) {
return next(err);
}
helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => {
mailHelpers.sendSubscriptionConfirmed(list, data.email, subscription, err => {
if (err) {
return next(err);
}
helpers.captureFlashMessages(req, res, (err, flash) => {
res.redirect('/subscription/' + list.cid + '/subscribed-notice');
});
});
});
});
});
router.get('/confirm/change-address/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'change-address', 'Request invalid or already completed. If your address change request is still pending, please change the address again.', next, (confirmation, list) => {
const data = confirmation.data;
if (!data.subscriptionId) { // Something went terribly wrong and we don't have data that we have originally provided
return next(new Error(_('Subscriber info corrupted or missing')));
}
subscriptions.updateAddress(list.id, data.subscriptionId, data.emailNew, err => {
if (err) {
return next(err);
}
data.isWeb = true;
data.flashMessages = flash;
res.send(htmlRenderer(data));
});
});
});
if (configItems.disableConfirmations) {
return;
}
fields.list(list.id, (err, fieldList) => {
subscriptions.getById(list.id, data.subscriptionId, (err, subscription) => {
if (err) {
return log.error('Fields', err);
return next(err);
}
let encryptionKeys = [];
fields.getRow(fieldList, subscription).forEach(field => {
if (field.type === 'gpg' && field.value) {
encryptionKeys.push(field.value.trim());
}
});
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 => {
mailHelpers.sendSubscriptionConfirmed(list, data.emailNew, subscription, err => {
if (err) {
log.error('Subscription', err);
return next(err);
}
req.flash('info', _('Email address changed'));
res.redirect('/subscription/' + list.cid + '/manage/' + subscription.cid);
});
};
});
});
});
});
let text = {
template: 'subscription/mail-subscription-confirmed-text.hbs'
};
router.get('/confirm/unsubscribe/:cid', (req, res, next) => {
checkAndExecuteConfirmation(req, 'unsubscribe', 'Request invalid or already completed. If your unsubscription request is still pending, please unsubscribe again.', next, (confirmation, list) => {
const data = confirmation.data;
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) => {
subscriptions.changeStatus(list.id, confirmation.data.subscriptionId, confirmation.data.campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
return sendMail(html, text);
return next(err);
}
sendMail(tmpl.html, tmpl.text);
});
// TODO: Shall we do anything with "found"?
subscriptions.getById(list.id, confirmation.data.subscriptionId, (err, subscription) => {
if (err) {
return next(err);
}
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
});
});
});
@ -190,6 +184,8 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => {
return next(err);
}
// TODO: process subscriber cid param for resubscription requests
let data = tools.convertKeys(req.query, {
skip: ['layout']
});
@ -198,6 +194,8 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => {
data.cid = list.cid;
data.csrfToken = req.csrfToken();
function nextStep() {
fields.list(list.id, (err, fieldList) => {
if (err && !fieldList) {
fieldList = [];
@ -243,6 +241,27 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => {
});
});
});
}
const ucid = req.query.cid;
if (ucid) {
subscriptions.get(list.id, ucid, (err, subscription) => {
if (err) {
return next(err);
}
for (let key in subscription) {
if (!(key in data)) {
data[key] = subscription[key];
}
}
nextStep();
});
} else {
nextStep();
}
});
});
@ -318,164 +337,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,8 +357,17 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
tools.validateEmail(email, false, err => {
if (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));
}
// 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
// the subscriber 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
@ -520,24 +390,33 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r
return req.xhr ? sendJsonError(err) : next(err);
}
let data = {};
let subscriptionData = {};
Object.keys(req.body).forEach(key => {
if (key !== 'email' && key.charAt(0) !== '_') {
data[key] = (req.body[key] || '').toString().trim();
subscriptionData[key] = (req.body[key] || '').toString().trim();
}
});
subscriptionData = tools.convertKeys(subscriptionData);
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'));
subscriptions.getByEmail(list.id, email, (err, subscription) => {
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
if (subscription && subscription.status === subscriptions.Status.SUBSCRIBED) {
mailHelpers.sendAlreadySubscribed(list, email, subscription, (err) => {
if (err) {
return req.xhr ? sendJsonError(err) : next(err);
}
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
});
} else {
const data = {
email,
subscriptionData
};
confirmations.addConfirmation(list.id, 'subscribe', req.ip, data, (err, confirmCid) => {
if (err) {
if (req.xhr) {
return sendJsonError(err);
@ -546,12 +425,29 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
function sendWebResponse() {
if (req.xhr) {
return res.status(200).json({
msg: _('Please Confirm Subscription')
});
}
res.redirect('/subscription/' + req.params.cid + '/confirm-notice');
res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice');
}
if (!testsPass) {
log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data));
sendWebResponse();
} else {
mailHelpers.sendConfirmSubscription(list, email, confirmCid, data, (err) => {
if (err) {
return req.xhr ? sendJsonError(err) : sendWebResponse(err);
}
sendWebResponse();
})
}
});
}
});
});
});
});
@ -572,8 +468,8 @@ router.get('/:lcid/manage/: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'));
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
@ -643,15 +539,24 @@ router.post('/:lcid/manage', passport.parseForm, passport.csrfProtection, (req,
return next(err);
}
subscriptions.update(list.id, req.body.cid, req.body, false, err => {
subscriptions.get(list.id, req.body.cid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
if (err) {
req.flash('danger', err.message || err);
log.error('Subscription', err);
return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/manage/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body));
return next(err);
}
subscriptions.update(list.id, subscription.cid, req.body, false, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + req.params.lcid + '/updated-notice');
});
});
});
});
router.get('/:lcid/manage-address/:ucid', passport.csrfProtection, (req, res, next) => {
@ -671,8 +576,8 @@ router.get('/:lcid/manage-address/:ucid', passport.csrfProtection, (req, res, ne
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && !subscription) {
err = new Error(_('Subscription not found from this list'));
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
@ -725,16 +630,48 @@ router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection
return next(err);
}
subscriptions.updateAddress(list, req.body.cid, req.body, req.ip, err => {
let bodyData = tools.convertKeys(req.body); // This is here to convert "email-new" to "emailNew"
const emailOld = (bodyData.email || '').toString().trim();
const emailNew = (bodyData.emailNew || '').toString().trim();
if (emailOld === emailNew) {
req.flash('info', _('Nothing seems to be changed'));
res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid);
} else {
subscriptions.updateAddressCheck(list, req.body.cid, emailNew, req.ip, (err, subscription, newEmailAvailable) => {
if (err) {
req.flash('danger', err.message || err);
log.error('Subscription', err);
return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/manage-address/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body));
return next(err);
}
req.flash('info', _('Email address updated, check your mailbox for verification instructions'));
function sendWebResponse(err) {
if (err) {
return next(err);
}
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);
}
if (newEmailAvailable) {
const data = {
subscriptionId: subscription.id,
emailNew
};
confirmations.addConfirmation(list.id, 'change-address', req.ip, data, (err, confirmCid) => {
if (err) {
return next(err);
}
mailHelpers.sendConfirmAddressChange(list, emailNew, confirmCid, subscription, sendWebResponse);
});
} else {
mailHelpers.sendAlreadySubscribed(list, emailNew, subscription, sendWebResponse);
}
});
}
});
});
@ -755,8 +692,8 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next)
}
subscriptions.get(list.id, req.params.ucid, (err, subscription) => {
if (!err && !subscription) {
err = new Error(_('Subscription not found from this list'));
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
@ -764,10 +701,20 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next)
return next(err);
}
const autoUnsubscribe = req.query.auto === 'yes';
if (autoUnsubscribe) {
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
} else if (req.query.formTest ||
list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM ||
list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
subscription.lcid = req.params.lcid;
subscription.ucid = req.params.ucid;
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;
@ -798,6 +745,9 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next)
});
});
});
} else { // UnsubscriptionMode.ONE_STEP || UnsubscriptionMode.TWO_STEP || UnsubscriptionMode.MANUAL
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
}
});
});
});
@ -814,86 +764,91 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (
return next(err);
}
let email = req.body.email;
const campaignId = (req.body.campaign || '').toString().trim() || false;
subscriptions.get(list.id, req.body.ucid, (err, subscription) => {
if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) {
err = new Error(_('Subscription not found in this list'));
err.status = 404;
}
subscriptions.unsubscribe(list.id, email, req.body.campaign, (err, subscription) => {
if (err) {
req.flash('danger', err.message || err);
log.error('Subscription', err);
return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/unsubscribe/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body));
return next(err);
}
res.redirect('/subscription/' + req.params.lcid + '/unsubscribe-notice');
fields.list(list.id, (err, fieldList) => {
handleUnsubscribe(list, subscription, false, campaignId, req.ip, res, next);
});
});
});
function handleUnsubscribe(list, subscription, autoUnsubscribe, campaignId, ip, res, next) {
if ((list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) ||
(autoUnsubscribe && (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM)) ) {
subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
return log.error('Fields', err);
return next(err);
}
let encryptionKeys = [];
fields.getRow(fieldList, subscription).forEach(field => {
if (field.type === 'gpg' && field.value) {
encryptionKeys.push(field.value.trim());
// TODO: Shall we do anything with "found"?
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
});
});
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', 'disableConfirmations'], (err, configItems) => {
if (err) {
return log.error('Settings', err);
}
} else if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
if (configItems.disableConfirmations) {
return;
}
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);
}
});
const data = {
subscriptionId: subscription.id,
campaignId
};
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) => {
confirmations.addConfirmation(list.id, 'unsubscribe', ip, data, (err, confirmCid) => {
if (err) {
return sendMail(html, text);
return next(err);
}
sendMail(tmpl.html, tmpl.text);
});
});
});
mailHelpers.sendConfirmUnsubscription(list, subscription.email, confirmCid, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/confirm-unsubscription-notice');
});
});
} else { // UnsubscriptionMode.MANUAL
res.redirect('/subscription/' + list.cid + '/manual-unsubscribe-notice');
}
}
router.get('/:cid/confirm-subscription-notice', (req, res, next) => {
webNotice('confirm-subscription', req, res, next);
});
router.get('/:cid/confirm-unsubscription-notice', (req, res, next) => {
webNotice('confirm-unsubscription', req, res, next);
});
router.get('/:cid/subscribed-notice', (req, res, next) => {
webNotice('subscribed', req, res, next);
});
router.get('/:cid/updated-notice', (req, res, next) => {
webNotice('updated', req, res, next);
});
router.get('/:cid/unsubscribed-notice', (req, res, next) => {
webNotice('unsubscribed', req, res, next);
});
router.get('/:cid/manual-unsubscribe-notice', (req, res, next) => {
webNotice('manual-unsubscribe', req, res, next);
});
router.post('/publickey', passport.parseForm, (req, res, next) => {
@ -934,4 +889,59 @@ router.post('/publickey', passport.parseForm, (req, res, next) => {
});
});
function webNotice(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', 'adminEmail'], (err, configItems) => {
if (err) {
return next(err);
}
let data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
contactAddress: configItems.defaultAddress,
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

@ -419,7 +419,7 @@ function formatMessage(message, callback) {
}
},
list: {
unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid + '?auto=yes')
unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid)
},
subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject),
html: renderedHtml,

View file

@ -2,10 +2,30 @@
# Define incrementing schema version number
SET @schema_version = '28';
# Rename column tracking_disabled
ALTER TABLE `campaigns` ADD COLUMN `open_tracking_disabled` tinyint(4) unsigned DEFAULT 0 NOT NULL, ADD COLUMN `click_tracking_disabled` tinyint(4) unsigned DEFAULT 0 NOT NULL;
UPDATE `campaigns` SET `open_tracking_disabled` = `tracking_disabled`, `click_tracking_disabled` = `tracking_disabled`;
ALTER TABLE `campaigns` DROP COLUMN `tracking_disabled`;
# Add unsubscription mode field to lists
ALTER TABLE `lists` ADD COLUMN `unsubscription_mode` int(11) unsigned DEFAULT 0 NOT NULL AFTER `public_subscribe`;
# Delete all confirmations as we use different structure in "data".
DELETE FROM `confirmations`;
# Change the name of the column to better reflect that confirmations are also used for unsubscription and email address update
# Drop email field as this does not have a clear semantics in change address. Since email is not used to search in the table,
# it can be stored in data
# Create field action to distinguish between different confirmation types (subscribe, unsubscribe, change-address)
ALTER TABLE `confirmations` CHANGE `opt_in_ip` `ip` varchar(100) DEFAULT NULL;
ALTER TABLE `confirmations` DROP `email`;
ALTER TABLE `confirmations` ADD COLUMN `action` varchar(100) NOT NULL AFTER `list`;
# 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;

View file

@ -0,0 +1,13 @@
# Header section
# Define incrementing schema version number
SET @schema_version = '29';
# Rename column tracking_disabled
ALTER TABLE `campaigns` ADD COLUMN `open_tracking_disabled` tinyint(4) unsigned DEFAULT 0 NOT NULL, ADD COLUMN `click_tracking_disabled` tinyint(4) unsigned DEFAULT 0 NOT NULL;
UPDATE `campaigns` SET `open_tracking_disabled` = `tracking_disabled`, `click_tracking_disabled` = `tracking_disabled`;
ALTER TABLE `campaigns` DROP COLUMN `tracking_disabled`;
# 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

@ -1,15 +0,0 @@
'use strict';
const config = require('./config');
const webdriver = require('selenium-webdriver');
const driver = new webdriver.Builder()
.forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs')
.build();
if (global.USE_SHARED_DRIVER === true) {
driver.originalQuit = driver.quit;
driver.quit = () => {};
}
module.exports = driver;

View file

@ -1,36 +1,30 @@
'use strict';
require('./helpers/exit-unless-test');
require('./lib/exit-unless-test');
const { mocha, driver } = require('./lib/mocha-e2e');
const path = require('path');
global.USE_SHARED_DRIVER = true;
const driver = require('./helpers/driver');
const only = 'only';
const skip = 'skip';
let tests = [
['tests/login'],
['tests/subscription']
'login',
'subscription'
];
tests = tests.filter(t => t[1] !== skip);
if (tests.some(t => t[1] === only)) {
tests = tests.filter(t => t[1] === only);
tests = tests.map(testSpec => (testSpec.constructor === Array ? testSpec : [testSpec]));
tests = tests.filter(testSpec => testSpec[1] !== skip);
if (tests.some(testSpec => testSpec[1] === only)) {
tests = tests.filter(testSpec => testSpec[1] === only);
}
describe('e2e', function() {
this.timeout(10000);
for (const testSpec of tests) {
const testPath = path.join(__dirname, 'tests', testSpec[0] + '.js');
mocha.addFile(testPath);
}
tests.forEach(t => {
describe(t[0], () => {
require('./' + t[0]); // eslint-disable-line global-require
});
});
after(() => driver.originalQuit());
mocha.run(failures => {
process.exit(failures); // exit with non-zero status if there were failures
});

View file

@ -5,6 +5,7 @@ const config = require('config');
module.exports = {
app: config,
baseUrl: 'http://localhost:' + config.www.port,
mailUrl: 'http://localhost:' + config.testserver.mailboxserverport,
users: {
admin: {
username: 'admin',

19
test/e2e/lib/mail.js Normal file
View file

@ -0,0 +1,19 @@
'use strict';
const config = require('./config');
const driver = require('./mocha-e2e').driver;
const page = require('./page');
module.exports = (...extras) => page({
async fetchMail(address) {
await driver.sleep(1000);
await driver.navigate().to(`${config.mailUrl}/${address}`);
await this.waitUntilVisible();
},
async ensureUrl(path) {
throw new Error('Unsupported method.');
},
}, ...extras);

217
test/e2e/lib/mocha-e2e.js Normal file
View file

@ -0,0 +1,217 @@
'use strict';
const Mocha = require('mocha');
const color = Mocha.reporters.Base.color;
const Semaphore = require('./semaphore');
const fs = require('fs-extra');
const config = require('./config');
const webdriver = require('selenium-webdriver');
const driver = new webdriver.Builder()
.forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs')
.build();
const failHandlerRunning = new Semaphore();
function UseCaseReporter(runner) {
Mocha.reporters.Base.call(this, runner);
const self = this;
let indents = 0;
function indent () {
return Array(indents).join(' ');
}
runner.on('start', function () {
console.log();
});
runner.on('suite', suite => {
++indents;
console.log(color('suite', '%s%s'), indent(), suite.title);
});
runner.on('suite end', () => {
--indents;
if (indents === 1) {
console.log();
}
});
runner.on('use-case', useCase => {
++indents;
console.log();
console.log(color('suite', '%sUse case: %s'), indent(), useCase.title);
});
runner.on('use-case end', () => {
--indents;
});
runner.on('steps', useCase => {
++indents;
console.log(color('pass', '%s%s'), indent(), useCase.title);
});
runner.on('steps end', () => {
--indents;
});
runner.on('step pass', step => {
console.log(indent() + color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) + color('pass', ' %s'), step.title);
});
runner.on('step fail', step => {
console.log(indent() + color('fail', ' %s'), step.title);
});
runner.on('pending', test => {
const fmt = indent() + color('pending', ' - %s');
console.log(fmt, test.title);
});
runner.on('pass', test => {
let fmt;
if (test.speed === 'fast') {
fmt = indent() +
color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) +
color('pass', ' %s');
console.log(fmt, test.title);
} else {
fmt = indent() +
color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) +
color('pass', ' %s') +
color(test.speed, ' (%dms)');
console.log(fmt, test.title, test.duration);
}
});
runner.on('fail', (test, err) => {
failHandlerRunning.enter();
(async () => {
const currentUrl = await driver.getCurrentUrl();
const info = `URL: ${currentUrl}`;
await fs.writeFile('last-failed-e2e-test.info', info);
await fs.writeFile('last-failed-e2e-test.html', await driver.getPageSource());
await fs.writeFile('last-failed-e2e-test.png', new Buffer(await driver.takeScreenshot(), 'base64'));
failHandlerRunning.exit();
})();
console.log(indent() + color('fail', ' %s'), test.title);
console.log();
console.log(err);
console.log();
console.log(`Snaphot of and info about the current page are in last-failed-e2e-test.*`);
});
runner.on('end', () => {
const stats = self.stats;
let fmt;
console.log();
// passes
fmt = color('bright pass', ' ') + color('green', ' %d passing');
console.log(fmt, stats.passes);
// pending
if (stats.pending) {
fmt = color('pending', ' ') + color('pending', ' %d pending');
console.log(fmt, stats.pending);
}
// failures
if (stats.failures) {
fmt = color('fail', ' %d failing');
console.log(fmt, stats.failures);
}
console.log();
});
}
const mocha = new Mocha()
.timeout(120000)
.reporter(UseCaseReporter)
.ui('tdd');
mocha._originalRun = mocha.run;
let runner;
mocha.run = fn => {
runner = mocha._originalRun(async () => {
await failHandlerRunning.waitForEmpty();
await driver.quit();
fn();
});
};
async function useCaseExec(name, asyncFn) {
runner.emit('use-case', {title: name});
try {
await asyncFn();
runner.emit('use-case end');
} catch (err) {
runner.emit('use-case end');
throw err;
}
}
function useCase(name, asyncFn) {
if (asyncFn) {
return test('Use case: ' + name, () => useCaseExec(name, asyncFn));
} else {
// Pending test
return test('Use case: ' + name);
}
}
useCase.only = (name, asyncFn) => {
return test.only('Use case: ' + name, () => useCaseExec(name, asyncFn));
};
useCase.skip = (name, asyncFn) => {
return test.skip('Use case: ' + name, () => useCaseExec(name, asyncFn));
};
async function step(name, asyncFn) {
try {
await asyncFn();
runner.emit('step pass', {title: name});
} catch (err) {
runner.emit('step fail', {title: name});
throw err;
}
}
async function steps(name, asyncFn) {
try {
runner.emit('steps', {title: name});
await asyncFn();
runner.emit('steps end');
} catch (err) {
runner.emit('step end');
throw err;
}
}
async function precondition(preConditionName, useCaseName, asyncFn) {
await steps(`Including use case "${useCaseName}" to satisfy precondition "${preConditionName}"`, asyncFn);
}
module.exports = {
mocha,
useCase,
step,
steps,
precondition,
driver
};

122
test/e2e/lib/page.js Normal file
View file

@ -0,0 +1,122 @@
'use strict';
const config = require('./config');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;
const until = webdriver.until;
const fs = require('fs-extra');
const driver = require('./mocha-e2e').driver;
const url = require('url');
const UrlPattern = require('url-pattern');
const waitTimeout = 10000;
module.exports = (...extras) => Object.assign({
elements: {},
async getElement(key) {
return await driver.findElement(By.css(this.elements[key]));
},
async getLinkParams(key) {
const elem = await driver.findElement(By.css(this.elements[key]));
const linkUrl = await elem.getAttribute('href');
const linkPath = url.parse(linkUrl).path;
const urlPattern = new UrlPattern(this.links[key]);
const params = urlPattern.match(linkPath);
if (!params) {
throw new Error(`Cannot match URL pattern ${this.links[key]}`);
}
return params;
},
async waitUntilVisible(selector) {
await driver.wait(until.elementLocated(By.css('body')), waitTimeout);
for (const elem of (this.elementsToWaitFor || [])) {
const sel = this.elements[elem];
if (!sel) {
throw new Error(`Element "${elem}" not found.`);
}
await driver.wait(until.elementLocated(By.css(sel)), waitTimeout);
}
for (const text of (this.textsToWaitFor || [])) {
await driver.wait(new webdriver.Condition(`for text "${text}"`, async (driver) => {
return await this.containsText(text);
}), waitTimeout);
}
if (this.url) {
await this.ensureUrl();
}
await driver.executeScript('document.mailTrainRefreshAcknowledged = true;');
},
async waitUntilVisibleAfterRefresh(selector) {
await driver.wait(new webdriver.Condition('for refresh', async (driver) => {
const val = await driver.executeScript('return document.mailTrainRefreshAcknowledged;');
return !val;
}), waitTimeout);
await this.waitUntilVisible(selector);
},
async click(key) {
const elem = await this.getElement(key);
await elem.click();
},
async getHref(key) {
const elem = await this.getElement(key);
return await elem.getAttribute('href');
},
async getText(key) {
const elem = await this.getElement(key);
return await elem.getText();
},
async getValue(key) {
const elem = await this.getElement(key);
return await elem.getAttribute('value');
},
async containsText(str) {
return await driver.executeScript(`
return (document.documentElement.innerText || document.documentElement.textContent).indexOf('${str}') > -1;
`);
},
async getSource() {
return await driver.getPageSource();
},
async saveSource(destPath) {
const src = await this.getSource();
await fs.writeFile(destPath, src);
},
async saveScreenshot(destPath) {
const pngData = await driver.takeScreenshot();
const buf = new Buffer(pngData, 'base64');
await fs.writeFile(destPath, buf);
},
async saveSnapshot(destPathBase) {
destPathBase = destPathBase || 'last-failed-e2e-test';
const currentUrl = await driver.getCurrentUrl();
const info = `URL: ${currentUrl}`;
await fs.writeFile(destPathBase + '.info', info);
await this.saveSource(destPathBase + '.html');
await this.saveScreenshot(destPathBase + '.png');
},
async sleep(ms) {
await driver.sleep(ms);
}
}, ...extras);

35
test/e2e/lib/semaphore.js Normal file
View file

@ -0,0 +1,35 @@
'use strict';
const Promise = require('bluebird');
class Semaphore {
constructor() {
this.counter = 0;
}
enter() {
this.counter++;
}
exit() {
this.counter--;
}
async waitForEmpty() {
const self = this;
function wait(resolve) {
if (self.counter == 0) {
resolve();
} else {
setTimeout(wait, 500, resolve);
}
}
return new Promise(resolve => {
setTimeout(wait, 500, resolve);
})
}
}
module.exports = Semaphore;

77
test/e2e/lib/web.js Normal file
View file

@ -0,0 +1,77 @@
'use strict';
const config = require('./config');
const By = require('selenium-webdriver').By;
const url = require('url');
const UrlPattern = require('url-pattern');
const driver = require('./mocha-e2e').driver;
const page = require('./page');
module.exports = (...extras) => page({
async navigate(pathOrParams) {
let path;
if (typeof pathOrParams === 'string') {
path = pathOrParams;
} else {
const urlPattern = new UrlPattern(this.requestUrl || this.url);
path = urlPattern.stringify(pathOrParams)
}
const parsedUrl = url.parse(path);
let absolutePath;
if (parsedUrl.host) {
absolutePath = path;
} else {
absolutePath = config.baseUrl + path;
}
await driver.navigate().to(absolutePath);
await this.waitUntilVisible();
},
async ensureUrl(path) {
const desiredUrl = path || this.url;
if (desiredUrl) {
const currentUrl = url.parse(await driver.getCurrentUrl());
const urlPattern = new UrlPattern(desiredUrl);
const params = urlPattern.match(currentUrl.pathname);
if (!params || config.baseUrl !== `${currentUrl.protocol}//${currentUrl.host}`) {
throw new Error(`Unexpected URL. Expecting ${config.baseUrl}${this.url} got ${currentUrl.protocol}//${currentUrl.host}/${currentUrl.pathname}`);
}
this.params = params;
}
},
async submit() {
const submitButton = await this.getElement('submitButton');
await submitButton.click();
},
async waitForFlash() {
await this.waitUntilVisible('div.alert:not(.js-warning)');
},
async getFlash() {
const elem = await driver.findElement(By.css('div.alert:not(.js-warning)'));
return await elem.getText();
},
async clearFlash() {
await driver.executeScript(`
var elements = document.getElementsByClassName('alert');
while(elements.length > 0){
elements[0].parentNode.removeChild(elements[0]);
}
`);
},
async setValue(key, value) {
const elem = await this.getElement(key);
await elem.clear();
await elem.sendKeys(value);
}
}, ...extras);

View file

@ -1,21 +0,0 @@
'use strict';
const page = require('./page');
module.exports = driver => Object.assign(page(driver), {
elementToWaitFor: 'alert',
elements: {
alert: 'div.alert:not(.js-warning)'
},
getText() {
return this.element('alert').getText();
},
clear() {
return this.driver.executeScript(`
var elements = document.getElementsByClassName('alert');
while(elements.length > 0){
elements[0].parentNode.removeChild(elements[0]);
}
`);
}
});

View file

@ -1,11 +1,7 @@
'use strict';
const page = require('./page');
const web = require('../lib/web');
module.exports = driver => Object.assign(page(driver), {
url: '/',
elementToWaitFor: 'body',
elements: {
body: 'body.page--home'
}
module.exports = web({
url: '/'
});

View file

@ -1,55 +0,0 @@
'use strict';
const config = require('../helpers/config');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;
const until = webdriver.until;
module.exports = driver => ({
driver,
elements: {},
element(key) {
return this.driver.findElement(By.css(this.elements[key] || key));
},
navigate(path) {
this.driver.navigate().to(config.baseUrl + (path || this.url));
return this.waitUntilVisible();
},
waitUntilVisible() {
let selector = this.elements[this.elementToWaitFor];
if (!selector && this.url) {
selector = 'body.page--' + (this.url.substring(1).replace(/\//g, '--') || 'home');
}
return selector ? this.driver.wait(until.elementLocated(By.css(selector))) : this.driver.sleep(1000);
},
submit() {
return this.element('submitButton').click();
},
click(key) {
return this.element(key).click();
},
getText(key) {
return this.element(key).getText();
},
getValue(key) {
return this.element(key).getAttribute('value');
},
setValue(key, value) {
return this.element(key).sendKeys(value);
},
containsText(str) {
// let text = await driver.findElement({ css: 'body' }).getText();
return this.driver.executeScript(`
return (document.documentElement.textContent || document.documentElement.innerText).indexOf('${str}') > -1;
`);
}
});

View file

@ -1,84 +1,132 @@
'use strict';
const config = require('../helpers/config');
const page = require('./page');
const config = require('../lib/config');
const web = require('../lib/web');
const mail = require('../lib/mail');
const web = {
enterEmail(value) {
this.element('emailInput').clear();
return this.element('emailInput').sendKeys(value);
}
};
module.exports = list => ({
const mail = {
navigate(address) {
this.driver.sleep(100);
this.driver.navigate().to(`http://localhost:${config.app.testserver.mailboxserverport}/${address}`);
return this.waitUntilVisible();
}
};
module.exports = (driver, list) => ({
webSubscribe: Object.assign(page(driver), web, {
webSubscribe: web({
url: `/subscription/${list.cid}`,
elementToWaitFor: 'form',
elementsToWaitFor: ['form'],
textsToWaitFor: ['Subscribe to list'],
elements: {
form: `form[action="/subscription/${list.cid}/subscribe"]`,
emailInput: '#main-form input[name="email"]',
firstNameInput: '#main-form input[name="first-name"]',
lastNameInput: '#main-form input[name="last-name"]',
submitButton: 'a[href="#submit"]'
}
}),
webConfirmSubscriptionNotice: Object.assign(page(driver), web, {
url: `/subscription/${list.cid}/confirm-notice`,
elementToWaitFor: 'homepageButton',
webConfirmSubscriptionNotice: web({
url: `/subscription/${list.cid}/confirm-subscription-notice`,
textsToWaitFor: ['We need to confirm your email address']
}),
mailConfirmSubscription: mail({
elementsToWaitFor: ['confirmLink'],
textsToWaitFor: ['Please Confirm Subscription'],
elements: {
homepageButton: `a[href="${config.settings['default-homepage']}"]`
confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/subscribe/"]`
}
}),
mailConfirmSubscription: Object.assign(page(driver), mail, {
elementToWaitFor: 'confirmLink',
mailAlreadySubscribed: mail({
elementsToWaitFor: ['unsubscribeLink'],
textsToWaitFor: ['Email address already registered'],
elements: {
confirmLink: `a[href^="${config.settings['service-url']}subscription/subscribe/"]`
unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`,
manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]`
},
links: {
unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`,
manageLink: `/subscription/${list.cid}/manage/:ucid`
}
}),
webSubscribedNotice: Object.assign(page(driver), web, {
elementToWaitFor: 'homepageButton',
webSubscribedNotice: web({
url: `/subscription/${list.cid}/subscribed-notice`,
textsToWaitFor: ['Subscription Confirmed']
}),
mailSubscriptionConfirmed: mail({
elementsToWaitFor: ['unsubscribeLink'],
textsToWaitFor: ['Subscription Confirmed'],
elements: {
homepageButton: 'a[href^="https://mailtrain.org"]'
unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`,
manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]`
},
links: {
unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`,
manageLink: `/subscription/${list.cid}/manage/:ucid`
}
}),
mailSubscriptionConfirmed: Object.assign(page(driver), mail, {
elementToWaitFor: 'unsubscribeLink',
webManage: web({
url: `/subscription/${list.cid}/manage/:ucid`,
elementsToWaitFor: ['form'],
textsToWaitFor: ['Update Your Preferences'],
elements: {
unsubscribeLink: 'a[href*="/unsubscribe/"]',
manageLink: 'a[href*="/manage/"]'
form: `form[action="/subscription/${list.cid}/manage"]`,
emailInput: '#main-form input[name="email"]',
firstNameInput: '#main-form input[name="first-name"]',
lastNameInput: '#main-form input[name="last-name"]',
submitButton: 'a[href="#submit"]',
manageAddressLink: `a[href^="/subscription/${list.cid}/manage-address/"]`
},
links: {
manageAddressLink: `/subscription/${list.cid}/manage-address/:ucid`
}
}),
webUnsubscribe: Object.assign(page(driver), web, {
elementToWaitFor: 'submitButton',
webManageAddress: web({
url: `/subscription/${list.cid}/manage-address/:ucid`,
elementsToWaitFor: ['form'],
textsToWaitFor: ['Update Your Email Address'],
elements: {
submitButton: 'a[href="#submit"]'
form: `form[action="/subscription/${list.cid}/manage-address"]`,
emailInput: '#main-form input[name="email"]',
emailNewInput: '#main-form input[name="email-new"]',
submitButton: 'a[href="#submit"]',
}
}),
webUnsubscribedNotice: Object.assign(page(driver), web, {
elementToWaitFor: 'homepageButton',
mailConfirmAddressChange: mail({
elementsToWaitFor: ['confirmLink'],
textsToWaitFor: ['Please Confirm Subscription Address Change'],
elements: {
homepageButton: 'a[href^="https://mailtrain.org"]'
confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/change-address/"]`
}
}),
mailUnsubscriptionConfirmed: Object.assign(page(driver), mail, {
elementToWaitFor: 'resubscribeLink',
webUpdatedNotice: web({
url: `/subscription/${list.cid}/updated-notice`,
textsToWaitFor: ['Profile Updated'],
}),
webUnsubscribedNotice: web({
url: `/subscription/${list.cid}/unsubscribed-notice`,
textsToWaitFor: ['Unsubscribe Successful'],
}),
mailUnsubscriptionConfirmed: mail({
elementsToWaitFor: ['resubscribeLink'],
textsToWaitFor: ['You Are Now Unsubscribed'],
elements: {
resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]`
}
})
}),
/*
webUnsubscribe: web({ // FIXME
elementsToWaitFor: ['submitButton'],
elements: {
submitButton: 'a[href="#submit"]'
}
}),
*/
});

View file

@ -1,32 +1,29 @@
'use strict';
const page = require('./page');
const web = require('../lib/web');
module.exports = driver => ({
login: Object.assign(page(driver), {
module.exports = {
login: web({
url: '/users/login',
elementToWaitFor: 'submitButton',
elementsToWaitFor: ['submitButton'],
elements: {
usernameInput: 'form[action="/users/login"] input[name="username"]',
passwordInput: 'form[action="/users/login"] input[name="password"]',
submitButton: 'form[action="/users/login"] [type=submit]'
},
enterUsername(value) {
// this.element('usernameInput').clear();
return this.element('usernameInput').sendKeys(value);
},
enterPassword(value) {
return this.element('passwordInput').sendKeys(value);
}
}),
account: Object.assign(page(driver), {
logout: web({
requestUrl: '/users/logout',
url: '/'
}),
account: web({
url: '/users/account',
elementToWaitFor: 'emailInput',
elementsToWaitFor: ['form'],
elements: {
form: `form[action="/users/account"]`,
emailInput: 'form[action="/users/account"] input[name="email"]'
}
})
});
}),
};

View file

@ -1,57 +1,68 @@
'use strict';
const config = require('../helpers/config');
const config = require('../lib/config');
const { useCase, step, driver } = require('../lib/mocha-e2e');
const expect = require('chai').expect;
const driver = require('../helpers/driver');
const home = require('../page-objects/home')(driver);
const flash = require('../page-objects/flash')(driver);
const {
login,
account
} = require('../page-objects/users')(driver);
describe('login', function() {
this.timeout(10000);
const page = require('../page-objects/user');
const home = require('../page-objects/home');
suite('Login use-cases', function() {
before(() => driver.manage().deleteAllCookies());
it('can access home page', async () => {
test('User can access home page', async () => {
await home.navigate();
});
it('can not access restricted content', async () => {
driver.navigate().to(config.baseUrl + '/settings');
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('Need to be logged in to access restricted content');
await flash.clear();
test('Anonymous user cannot access restricted content', async () => {
await driver.navigate().to(config.baseUrl + '/settings');
await page.login.waitUntilVisible();
await page.login.waitForFlash();
expect(await page.login.getFlash()).to.contain('Need to be logged in to access restricted content');
});
it('can not login with false credentials', async () => {
login.enterUsername(config.users.admin.username);
login.enterPassword('invalid');
login.submit();
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('Incorrect username or password');
await flash.clear();
useCase('Login (invalid credential)', async () => {
await step('User navigates to the login page.', async () => {
await page.login.navigate();
});
it('can login as admin', async () => {
login.enterUsername(config.users.admin.username);
login.enterPassword(config.users.admin.password);
login.submit();
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('Logged in as admin');
await step('User fills in the user name and incorrect password.', async () => {
await page.login.setValue('usernameInput', config.users.admin.username);
await page.login.setValue('passwordInput', 'invalid');
await page.login.submit();
});
it('can access account page as admin', async () => {
await account.navigate();
await step('System shows a flash notice that credentials are invalid.', async () => {
await page.login.waitForFlash();
expect(await page.login.getFlash()).to.contain('Incorrect username or password');
});
});
it('can logout', async () => {
driver.navigate().to(config.baseUrl + '/users/logout');
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('logged out');
useCase('Login and logout', async () => {
await step('User navigates to the login page.', async () => {
await page.login.navigate();
});
after(() => driver.quit());
await step('User fills in the user name and password.', async () => {
await page.login.setValue('usernameInput', config.users.admin.username);
await page.login.setValue('passwordInput', config.users.admin.password);
await page.login.submit();
});
await step('System shows the home page and a flash notice that user has been logged in.', async () => {
await home.waitUntilVisibleAfterRefresh();
await home.waitForFlash();
expect(await home.getFlash()).to.contain('Logged in as admin');
});
await step('User navigates to its account.', async () => {
await page.account.navigate();
});
await step('User logs out.', async () => {
await page.logout.navigate();
await home.waitForFlash();
expect(await home.getFlash()).to.contain('logged out');
});
});
});

View file

@ -1,101 +1,248 @@
'use strict';
const config = require('../helpers/config');
const config = require('../lib/config');
const { useCase, step, precondition, driver } = require('../lib/mocha-e2e');
const shortid = require('shortid');
const expect = require('chai').expect;
const driver = require('../helpers/driver');
const page = require('../page-objects/page')(driver);
const flash = require('../page-objects/flash')(driver);
const page = require('../page-objects/subscription')(config.lists.one);
const {
webSubscribe,
webConfirmSubscriptionNotice,
mailConfirmSubscription,
webSubscribedNotice,
mailSubscriptionConfirmed,
webUnsubscribe,
webUnsubscribedNotice,
mailUnsubscriptionConfirmed
} = require('../page-objects/subscription')(driver, config.lists.one);
function generateEmail() {
return 'keep.' + shortid.generate() + '@mailtrain.org';
}
const testuser = {
email: 'keep.' + shortid.generate() + '@mailtrain.org'
};
async function subscribe(subscription) {
await step('User navigates to list subscription page.', async () => {
await page.webSubscribe.navigate();
});
// console.log(testuser.email);
await step('User submits a valid email and other subscription info.', async () => {
await page.webSubscribe.setValue('emailInput', subscription.email);
describe('subscribe (list one)', function() {
this.timeout(10000);
if (subscription.firstName) {
await page.webSubscribe.setValue('firstNameInput', subscription.firstName);
}
if (subscription.lastName) {
await page.webSubscribe.setValue('lastNameInput', subscription.lastName);
}
await page.webSubscribe.submit();
});
await step('System shows a notice that further instructions are in the email.', async () => {
await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email with a link to confirm the subscription.', async () => {
await page.mailConfirmSubscription.fetchMail(subscription.email);
});
await step('User clicks confirm subscription in the email', async () => {
await page.mailConfirmSubscription.click('confirmLink');
});
await step('System shows a notice that subscription has been confirmed.', async () => {
await page.webSubscribedNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email with subscription confirmation.', async () => {
await page.mailSubscriptionConfirmed.fetchMail(subscription.email);
subscription.unsubscribeLink = await page.mailSubscriptionConfirmed.getHref('unsubscribeLink');
subscription.manageLink = await page.mailSubscriptionConfirmed.getHref('manageLink');
const unsubscribeParams = await page.mailSubscriptionConfirmed.getLinkParams('unsubscribeLink');
const manageParams = await page.mailSubscriptionConfirmed.getLinkParams('manageLink');
expect(unsubscribeParams.ucid).to.equal(manageParams.ucid);
subscription.ucid = unsubscribeParams.ucid;
});
return subscription;
}
async function subscriptionExistsPrecondition(subscription) {
await precondition('Subscription exists', 'Subscription to a public list (main scenario)', async () => {
await subscribe(subscription);
});
return subscription;
}
suite('Subscription use-cases', function() {
before(() => driver.manage().deleteAllCookies());
it('visits web-subscribe', async () => {
await webSubscribe.navigate();
useCase('Subscription to a public list (main scenario)', async () => {
await subscribe({
email: generateEmail()
});
});
it('submits invalid email (error)', async () => {
webSubscribe.enterEmail('foo@bar.nope');
webSubscribe.submit();
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('Invalid email address');
useCase('Subscription to a public list (invalid email)', async () => {
await step('User navigates to list subscribe page', async () => {
await page.webSubscribe.navigate();
});
it('submits valid email', async () => {
webSubscribe.enterEmail(testuser.email);
await webSubscribe.submit();
await step('User submits an invalid email.', async () => {
await page.webSubscribe.setValue('emailInput', 'foo@bar.nope');
await page.webSubscribe.submit();
});
it('sees web-confirm-subscription-notice', async () => {
webConfirmSubscriptionNotice.waitUntilVisible();
expect(await page.containsText('Almost Finished')).to.be.true;
await step('System shows a flash notice that email is invalid.', async () => {
await page.webSubscribe.waitForFlash();
expect(await page.webSubscribe.getFlash()).to.contain('Invalid email address');
});
});
it('receives mail-confirm-subscription', async () => {
mailConfirmSubscription.navigate(testuser.email);
expect(await page.containsText('Please Confirm Subscription')).to.be.true;
useCase('Subscription to a public list (email already registered)', async () => {
const subscription = await subscriptionExistsPrecondition({
email: generateEmail()
});
it('clicks confirm subscription', async () => {
await mailConfirmSubscription.click('confirmLink');
await step('User navigates to list subscribe page', async () => {
await page.webSubscribe.navigate();
});
it('sees web-subscribed-notice', async () => {
webSubscribedNotice.waitUntilVisible();
expect(await page.containsText('Subscription Confirmed')).to.be.true;
await step('User submits the email which has been already registered.', async () => {
await page.webSubscribe.setValue('emailInput', subscription.email);
await page.webSubscribe.submit();
});
it('receives mail-subscription-confirmed', async () => {
mailSubscriptionConfirmed.navigate(testuser.email);
expect(await page.containsText('Subscription Confirmed')).to.be.true;
await step('System shows a notice that further instructions are in the email.', async () => {
await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh();
});
});
describe('unsubscribe (list one)', function() {
this.timeout(10000);
it('clicks unsubscribe', async () => {
await mailSubscriptionConfirmed.click('unsubscribeLink');
});
it('sees web-unsubscribe', async () => {
webUnsubscribe.waitUntilVisible();
expect(await page.containsText('Unsubscribe')).to.be.true;
});
it('clicks confirm unsubscription', async () => {
await webUnsubscribe.submit();
});
it('sees web-unsubscribed-notice', async () => {
webUnsubscribedNotice.waitUntilVisible();
expect(await page.containsText('Unsubscribe Successful')).to.be.true;
});
it('receives mail-unsubscription-confirmed', async () => {
mailUnsubscriptionConfirmed.navigate(testuser.email);
expect(await page.containsText('You Are Now Unsubscribed')).to.be.true;
});
after(() => driver.quit());
await step('System sends an email informing that the address has been already registered.', async () => {
await page.mailAlreadySubscribed.fetchMail(subscription.email);
});
});
useCase('Subscription to a non-public list');
useCase('Change profile info', async () => {
const subscription = await subscriptionExistsPrecondition({
email: generateEmail(),
firstName: 'John',
lastName: 'Doe'
});
await step('User clicks the manage subscription button.', async () => {
await page.mailSubscriptionConfirmed.click('manageLink');
});
await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => {
await page.webManage.waitUntilVisibleAfterRefresh();
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName);
expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName);
});
await step('User enters another name and submits the form.', async () => {
subscription.firstName = 'Adam';
subscription.lastName = 'B';
await page.webManage.setValue('firstNameInput', subscription.firstName);
await page.webManage.setValue('lastNameInput', subscription.lastName);
await page.webManage.submit();
});
await step('Systems shows a notice that profile has been updated.', async () => {
await page.webUpdatedNotice.waitUntilVisibleAfterRefresh();
});
await step('User navigates to manage subscription again.', async () => {
// await page.webManage.navigate(subscription.manageLink);
await page.webManage.navigate({ ucid: subscription.ucid });
});
await step('Systems shows a form with the changes made previously.', async () => {
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName);
expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName);
});
});
useCase('Change email', async () => {
const subscription = await subscriptionExistsPrecondition({
email: generateEmail(),
firstName: 'John',
lastName: 'Doe'
});
await step('User clicks the manage subscription button.', async () => {
await page.mailSubscriptionConfirmed.click('manageLink');
});
await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => {
await page.webManage.waitUntilVisibleAfterRefresh();
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName);
expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName);
});
await step('User clicks the change address button.', async () => {
await page.webManage.click('manageAddressLink');
});
await step('Systems shows a form to change email.', async () => {
await page.webManageAddress.waitUntilVisibleAfterRefresh();
});
await step('User fills in a new email address and submits the form.', async () => {
subscription.email = generateEmail();
await page.webManageAddress.setValue('emailNewInput', subscription.email);
await page.webManageAddress.submit();
});
await step('System goes back to the profile form and shows a flash notice that further instructions are in the email.', async () => {
await page.webManage.waitUntilVisibleAfterRefresh();
await page.webManage.waitForFlash();
expect(await page.webManage.getFlash()).to.contain('An email with further instructions has been sent to the provided address');
});
await step('System sends an email with a link to confirm the address change.', async () => {
await page.mailConfirmAddressChange.fetchMail(subscription.email);
});
await step('User clicks confirm subscription in the email', async () => {
await page.mailConfirmAddressChange.click('confirmLink');
});
await step('System shows the profile form with a flash notice that address has been changed.', async () => {
await page.webManage.waitUntilVisibleAfterRefresh();
await page.webManage.waitForFlash();
expect(await page.webManage.getFlash()).to.contain('Email address changed');
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
});
await step('System sends an email with subscription confirmation.', async () => {
await page.mailSubscriptionConfirmed.fetchMail(subscription.email);
});
});
useCase('Unsubscription from list #1 (one-step, no form).', async () => {
const subscription = await subscriptionExistsPrecondition({
email: generateEmail()
});
await step('User clicks the unsubscribe button.', async () => {
await page.mailSubscriptionConfirmed.click('unsubscribeLink');
});
await step('System shows a notice that confirms unsubscription.', async () => {
await page.webUnsubscribedNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email that confirms unsubscription.', async () => {
await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email);
});
});
useCase('Unsubscription from list #2 (one-step, with form).');
useCase('Unsubscription from list #3 (two-step, no form).');
useCase('Unsubscription from list #4 (two-step, with form).');
useCase('Unsubscription from list #5 (manual unsubscribe).');
useCase('Resubscription.'); // This one is supposed to check that values pre-filled in resubscription (i.e. the re-subscribe link in unsubscription confirmation) are the same as the ones used before.
});

View file

@ -26,13 +26,29 @@
<hr />
<div class="col-sm-offset-2">
<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>
<hr />

View file

@ -56,13 +56,29 @@
<hr />
<div class="col-sm-offset-2">
<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>
<hr />

View file

@ -46,12 +46,20 @@
<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>
|
<a href="/subscription/{{list.cid}}/manual-unsubscribe-notice?fid={{form.id}}" target="_blank">{{#translate}}Manual Unsubscribe Notice{{/translate}}</a>
{{#if testUsers}}
|
<a href="/subscription/{{list.cid}}/unsubscribe/{{testUsers.0.cid}}?fid={{form.id}}&formTest=1" target="_blank">{{#translate}}Unsubscribe{{/translate}}</a>
|
<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 registered{{/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 registered{{/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

@ -1,6 +1,6 @@
{{#each customFields}}
{{#if typeSubsciptionEmail}}
{{#if typeSubscriptionEmail}}
<div class="form-group email">
<label for="email">{{#translate}}Email Address{{/translate}}</label>
{{#if ../isManagePreferences}}

View file

@ -10,6 +10,7 @@
<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="">
<input type="hidden" name="ucid" value="{{ucid}}">
{{> subscription_custom_fields}}

View file

@ -1,20 +1,13 @@
<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}}">
<input type="hidden" name="ucid" value="{{ucid}}">
<div class="form-group">
<label for="email">{{#translate}}Email address{{/translate}}</label>
<input type="email" name="email" id="email" placeholder="" value="{{email}}" autofocus required>
<input type="email" name="email" id="email" placeholder="" value="{{email}}" readonly>
</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

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

View file

@ -0,0 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Online Unsubscription Is Not Possible{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}Please contact us at{{/translate}} <a href="mailto:{{contactAddress}}">{{contactAddress}}</a> {{#translate}}to get removed from the list{{/translate}}.
</mj-text>
<mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
</mj-button>
</mj-column>
</mj-section>

View file

@ -3,9 +3,6 @@
<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>