Merge with upstream
This commit is contained in:
commit
25bb4afa80
60 changed files with 2177 additions and 1215 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,6 @@
|
||||||
|
/.idea
|
||||||
|
/last-failed-e2e-test.*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
5
app.js
5
app.js
|
@ -184,8 +184,9 @@ app.use((req, res, next) => {
|
||||||
res.locals.customScripts = config.customscripts || [];
|
res.locals.customScripts = config.customscripts || [];
|
||||||
|
|
||||||
let bodyClasses = [];
|
let bodyClasses = [];
|
||||||
app.get('env') === 'test' && bodyClasses.push('page--' + (req.path.substring(1).replace(/\//g, '--') || 'home'));
|
if (req.user) {
|
||||||
req.user && bodyClasses.push('logged-in user-' + req.user.username);
|
bodyClasses.push('logged-in user-' + req.user.username);
|
||||||
|
}
|
||||||
res.locals.bodyClass = bodyClasses.join(' ');
|
res.locals.bodyClass = bodyClasses.join(' ');
|
||||||
|
|
||||||
settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => {
|
settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => {
|
||||||
|
|
|
@ -22,7 +22,8 @@ module.exports = {
|
||||||
injectCustomFormData,
|
injectCustomFormData,
|
||||||
injectCustomFormTemplates,
|
injectCustomFormTemplates,
|
||||||
filterCustomFields,
|
filterCustomFields,
|
||||||
getMjmlTemplate
|
getMjmlTemplate,
|
||||||
|
rollbackAndReleaseConnection
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDefaultMergeTags(callback) {
|
function getDefaultMergeTags(callback) {
|
||||||
|
@ -124,7 +125,7 @@ function filterCustomFields(customFieldsIn = [], fieldIds = [], method = 'includ
|
||||||
id: 'email',
|
id: 'email',
|
||||||
name: 'Email Address',
|
name: 'Email Address',
|
||||||
type: 'Email',
|
type: 'Email',
|
||||||
typeSubsciptionEmail: true
|
typeSubscriptionEmail: true
|
||||||
}, {
|
}, {
|
||||||
id: 'firstname',
|
id: 'firstname',
|
||||||
name: 'First Name',
|
name: 'First Name',
|
||||||
|
@ -285,3 +286,10 @@ function captureFlashMessages(req, res, callback) {
|
||||||
callback(null, flash);
|
callback(null, flash);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rollbackAndReleaseConnection(connection, callback) {
|
||||||
|
connection.rollback(() => {
|
||||||
|
connection.release();
|
||||||
|
return callback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -1056,13 +1056,13 @@ module.exports.updateMessage = (message, status, updateSubscription, callback) =
|
||||||
|
|
||||||
let statusCode;
|
let statusCode;
|
||||||
if (status === 'unsubscribed') {
|
if (status === 'unsubscribed') {
|
||||||
statusCode = 2;
|
statusCode = subscriptions.Status.UNSUBSCRIBED;
|
||||||
}
|
} else if (status === 'bounced') {
|
||||||
if (status === 'bounced') {
|
statusCode = subscriptions.Status.BOUNCED;
|
||||||
statusCode = 3;
|
} else if (status === 'complained') {
|
||||||
}
|
statusCode = subscriptions.Status.COMPLAINED;
|
||||||
if (status === 'complained') {
|
} else {
|
||||||
statusCode = 4;
|
return callback(new Error(_('Unrecognized message status')));
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = 'UPDATE `campaigns` SET `' + status + '`=`' + status + '`+1 WHERE id=? LIMIT 1';
|
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) {
|
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 {
|
} else {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
91
lib/models/confirmations.js
Normal file
91
lib/models/confirmations.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
|
@ -14,22 +14,31 @@ let allowedKeys = [
|
||||||
'fields_shown_on_manage',
|
'fields_shown_on_manage',
|
||||||
'layout',
|
'layout',
|
||||||
'form_input_style',
|
'form_input_style',
|
||||||
'mail_confirm_html',
|
'web_subscribe',
|
||||||
'mail_confirm_text',
|
'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_html',
|
||||||
'mail_subscription_confirmed_text',
|
'mail_subscription_confirmed_text',
|
||||||
'mail_unsubscribe_confirmed_html',
|
|
||||||
'mail_unsubscribe_confirmed_text',
|
|
||||||
'web_confirm_notice',
|
|
||||||
'web_manage_address',
|
|
||||||
'web_manage',
|
'web_manage',
|
||||||
'web_subscribe',
|
'web_manage_address',
|
||||||
'web_subscribed',
|
'web_updated_notice',
|
||||||
'web_unsubscribe_notice',
|
|
||||||
'web_unsubscribe',
|
'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) => {
|
module.exports.list = (listId, callback) => {
|
||||||
listId = Number(listId) || 0;
|
listId = Number(listId) || 0;
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,18 @@ let segments = require('./segments');
|
||||||
let _ = require('../translate')._;
|
let _ = require('../translate')._;
|
||||||
let tableHelpers = require('../table-helpers');
|
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) => {
|
module.exports.list = (start, limit, callback) => {
|
||||||
tableHelpers.list('lists', ['*'], 'name', null, 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) => {
|
module.exports.create = (list, callback) => {
|
||||||
|
|
||||||
let data = tools.convertKeys(list);
|
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) => {
|
module.exports.delete = (id, callback) => {
|
||||||
id = Number(id) || 0;
|
id = Number(id) || 0;
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,8 @@ const ReportState = {
|
||||||
SCHEDULED: 0,
|
SCHEDULED: 0,
|
||||||
PROCESSING: 1,
|
PROCESSING: 1,
|
||||||
FINISHED: 2,
|
FINISHED: 2,
|
||||||
FAILED: 3
|
FAILED: 3,
|
||||||
|
MAX: 4
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports.ReportState = ReportState;
|
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;
|
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) => {
|
connection.query(query, (err, results) => {
|
||||||
|
connection.release();
|
||||||
if (err) {
|
if (err) {
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
return callback(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,8 @@ function listValues(filter, callback) {
|
||||||
filter = false;
|
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));
|
filter = [].concat(filter || []).map(key => tools.toDbKey(key));
|
||||||
|
|
||||||
db.getConnection((err, connection) => {
|
db.getConnection((err, connection) => {
|
||||||
|
|
|
@ -5,16 +5,20 @@ let shortid = require('shortid');
|
||||||
let tools = require('../tools');
|
let tools = require('../tools');
|
||||||
let helpers = require('../helpers');
|
let helpers = require('../helpers');
|
||||||
let fields = require('./fields');
|
let fields = require('./fields');
|
||||||
let geoip = require('geoip-ultralight');
|
|
||||||
let segments = require('./segments');
|
let segments = require('./segments');
|
||||||
let settings = require('./settings');
|
|
||||||
let mailer = require('../mailer');
|
|
||||||
let urllib = require('url');
|
|
||||||
let log = require('npmlog');
|
|
||||||
let _ = require('../translate')._;
|
let _ = require('../translate')._;
|
||||||
let util = require('util');
|
|
||||||
let tableHelpers = require('../table-helpers');
|
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) => {
|
module.exports.list = (listId, start, limit, callback) => {
|
||||||
listId = Number(listId) || 0;
|
listId = Number(listId) || 0;
|
||||||
if (!listId) {
|
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);
|
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();
|
meta.cid = meta.cid || shortid.generate();
|
||||||
|
|
||||||
fields.list(listId, (err, fieldList) => {
|
fields.list(listId, (err, fieldList) => {
|
||||||
|
@ -292,8 +116,8 @@ module.exports.insert = (listId, meta, subscription, callback) => {
|
||||||
let values = [];
|
let values = [];
|
||||||
|
|
||||||
let allowedKeys = ['first_name', 'last_name', 'tz', 'is_test'];
|
let allowedKeys = ['first_name', 'last_name', 'tz', 'is_test'];
|
||||||
Object.keys(subscription).forEach(key => {
|
Object.keys(subscriptionData).forEach(key => {
|
||||||
let value = subscription[key];
|
let value = subscriptionData[key];
|
||||||
key = tools.toDbKey(key);
|
key = tools.toDbKey(key);
|
||||||
if (key === 'tz') {
|
if (key === 'tz') {
|
||||||
value = (value || '').toString().toLowerCase().trim();
|
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);
|
keys.push(field.key);
|
||||||
values.push(field.value);
|
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';
|
let query = 'SELECT `id`, `status`, `cid` FROM `subscription__' + listId + '` WHERE `email`=? OR `cid`=? LIMIT 1';
|
||||||
connection.query(query, [meta.email, meta.cid], (err, rows) => {
|
connection.query(query, [meta.email, meta.cid], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let query;
|
let query;
|
||||||
|
@ -338,25 +159,26 @@ module.exports.insert = (listId, meta, subscription, callback) => {
|
||||||
let entryId = existing ? existing.id : false;
|
let entryId = existing ? existing.id : false;
|
||||||
|
|
||||||
meta.cid = existing ? rows[0].cid : meta.cid;
|
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 statusChange = !existing || existing.status !== meta.status;
|
||||||
let statusDirection;
|
let statusDirection;
|
||||||
|
|
||||||
|
if (existing && existing.status === Status.SUBSCRIBED && !meta.partial) {
|
||||||
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Email address already registered'))));
|
||||||
|
}
|
||||||
|
|
||||||
if (statusChange) {
|
if (statusChange) {
|
||||||
keys.push('status', 'status_change');
|
keys.push('status', 'status_change');
|
||||||
values.push(meta.status, new Date());
|
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) {
|
if (!keys.length) {
|
||||||
// nothing to update
|
// nothing to update
|
||||||
return connection.commit(err => {
|
return connection.commit(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, {
|
return callback(null, {
|
||||||
|
@ -380,10 +202,7 @@ module.exports.insert = (listId, meta, subscription, callback) => {
|
||||||
|
|
||||||
connection.query(query, queryArgs, (err, result) => {
|
connection.query(query, queryArgs, (err, result) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entryId = result.insertId || entryId;
|
entryId = result.insertId || entryId;
|
||||||
|
@ -391,17 +210,11 @@ module.exports.insert = (listId, meta, subscription, callback) => {
|
||||||
if (statusChange && statusDirection) {
|
if (statusChange && statusDirection) {
|
||||||
connection.query('UPDATE lists SET `subscribers`=`subscribers`' + statusDirection + '1 WHERE id=?', [listId], err => {
|
connection.query('UPDATE lists SET `subscribers`=`subscribers`' + statusDirection + '1 WHERE id=?', [listId], err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.commit(err => {
|
connection.commit(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, {
|
return callback(null, {
|
||||||
|
@ -414,10 +227,7 @@ module.exports.insert = (listId, meta, subscription, callback) => {
|
||||||
} else {
|
} else {
|
||||||
connection.commit(err => {
|
connection.commit(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, {
|
return callback(null, {
|
||||||
|
@ -575,7 +385,7 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cid) {
|
if (!cid) {
|
||||||
return callback(new Error(_('Missing subscription ID')));
|
return callback(new Error(_('Missing Subscription ID')));
|
||||||
}
|
}
|
||||||
|
|
||||||
fields.list(listId, (err, fieldList) => {
|
fields.list(listId, (err, fieldList) => {
|
||||||
|
@ -627,46 +437,7 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports.unsubscribe = (listId, email, campaignId, callback) => {
|
module.exports.changeStatus = (listId, id, campaignId, status, 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) => {
|
|
||||||
db.getConnection((err, connection) => {
|
db.getConnection((err, connection) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return callback(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) => {
|
connection.query('SELECT `status` FROM `subscription__' + listId + '` WHERE id=? LIMIT 1', [id], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rows || !rows.length) {
|
if (!rows || !rows.length) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(null, false));
|
||||||
connection.release();
|
|
||||||
return callback(null, false);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let oldStatus = rows[0].status;
|
let oldStatus = rows[0].status;
|
||||||
|
@ -697,31 +462,22 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
|
||||||
let statusDirection;
|
let statusDirection;
|
||||||
|
|
||||||
if (!statusChange) {
|
if (!statusChange) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(null, true));
|
||||||
connection.release();
|
|
||||||
return callback(null, true);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusChange && oldStatus === 1 || status === 1) {
|
if (statusChange && oldStatus === Status.SUBSCRIBED || status === Status.SUBSCRIBED) {
|
||||||
statusDirection = status === 1 ? '+' : '-';
|
statusDirection = status === Status.SUBSCRIBED ? '+' : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
connection.query('UPDATE `subscription__' + listId + '` SET `status`=?, `status_change`=NOW() WHERE id=? LIMIT 1', [status, id], err => {
|
connection.query('UPDATE `subscription__' + listId + '` SET `status`=?, `status_change`=NOW() WHERE id=? LIMIT 1', [status, id], err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!statusDirection) {
|
if (!statusDirection) {
|
||||||
return connection.commit(err => {
|
return connection.commit(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, true);
|
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 => {
|
connection.query('UPDATE `lists` SET `subscribers`=`subscribers`' + statusDirection + '1 WHERE id=? LIMIT 1', [listId], err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// status change is not related to a campaign or it marks message as bounced etc.
|
// 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 => {
|
return connection.commit(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, true);
|
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) => {
|
connection.query('SELECT `id` FROM `campaigns` WHERE `cid`=? LIMIT 1', [campaignId], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let campaign = rows && rows[0] || false;
|
let campaign = rows && rows[0] || false;
|
||||||
|
@ -764,10 +511,7 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => {
|
||||||
// should not happend
|
// should not happend
|
||||||
return connection.commit(err => {
|
return connection.commit(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, true);
|
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
|
// 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) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = 'UPDATE `campaign__' + campaign.id + '` SET `status`=? WHERE `list`=? AND `subscription`=? LIMIT 1';
|
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
|
// Updated tracker status
|
||||||
connection.query(query, values, err => {
|
connection.query(query, values, err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.commit(err => {
|
return connection.commit(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, true);
|
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 => {
|
connection.query('DELETE FROM `subscription__' + listId + '` WHERE cid=? LIMIT 1', [cid], err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscription.status !== 1) {
|
if (subscription.status !== Status.SUBSCRIBED) {
|
||||||
return connection.commit(err => {
|
return connection.commit(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, subscription.email);
|
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 => {
|
connection.query('UPDATE lists SET subscribers=subscribers-1 WHERE id=? LIMIT 1', [listId], err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.commit(err => {
|
connection.commit(err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return connection.rollback(() => {
|
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
|
||||||
connection.release();
|
|
||||||
return callback(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, subscription.email);
|
return callback(null, subscription.email);
|
||||||
|
@ -963,11 +686,10 @@ module.exports.updateImport = (listId, importId, data, callback) => {
|
||||||
connection.release();
|
connection.release();
|
||||||
return callback(null, affected);
|
return callback(null, affected);
|
||||||
});
|
});
|
||||||
return;
|
} else {
|
||||||
|
connection.release();
|
||||||
|
return callback(null, affected);
|
||||||
}
|
}
|
||||||
|
|
||||||
connection.release();
|
|
||||||
return callback(null, affected);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1075,13 +797,13 @@ module.exports.listImports = (listId, callback) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
|
Performs checks before update of an address. This includes finding the existing subscriber, validating the new email
|
||||||
updates = tools.convertKeys(updates);
|
and checking whether the new email does not conflict with other subscribers.
|
||||||
|
*/
|
||||||
|
module.exports.updateAddressCheck = (list, cid, emailNew, ip, callback) => {
|
||||||
cid = (cid || '').toString().trim();
|
cid = (cid || '').toString().trim();
|
||||||
|
|
||||||
let emailNew = (updates.emailNew || '').toString().trim();
|
|
||||||
|
|
||||||
if (!list || !list.id) {
|
if (!list || !list.id) {
|
||||||
return callback(new Error(_('Missing List ID')));
|
return callback(new Error(_('Missing List ID')));
|
||||||
}
|
}
|
||||||
|
@ -1100,7 +822,7 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
|
||||||
return callback(err);
|
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];
|
let args = [cid];
|
||||||
connection.query(query, args, (err, rows) => {
|
connection.query(query, args, (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
@ -1119,7 +841,7 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
|
||||||
|
|
||||||
let old = rows[0];
|
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];
|
let args = [emailNew, cid];
|
||||||
connection.query(query, args, (err, rows) => {
|
connection.query(query, args, (err, rows) => {
|
||||||
connection.release();
|
connection.release();
|
||||||
|
@ -1127,18 +849,77 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => {
|
||||||
return callback(err);
|
return callback(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rows && rows[0] && rows[0].id) {
|
if (rows && rows.length > 0) {
|
||||||
return callback(new Error(_('This address is already registered by someone else')));
|
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.
|
||||||
|
|
157
lib/subscription-mail-helpers.js
Normal file
157
lib/subscription-mail-helpers.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
13
lib/tools.js
13
lib/tools.js
|
@ -43,7 +43,13 @@ function toDbKey(key) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromDbKey(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) {
|
function convertKeys(obj, options) {
|
||||||
|
@ -54,7 +60,7 @@ function convertKeys(obj, options) {
|
||||||
if (options.skip && options.skip.indexOf(lKey) >= 0) {
|
if (options.skip && options.skip.indexOf(lKey) >= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.keep && options.skip.indexOf(lKey) < 0) {
|
if (options.keep && options.keep.indexOf(lKey) < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response[lKey] = obj[key];
|
response[lKey] = obj[key];
|
||||||
|
@ -142,7 +148,6 @@ function updateMenu(res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateEmail(address, checkBlocked, callback) {
|
function validateEmail(address, checkBlocked, callback) {
|
||||||
|
|
||||||
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
|
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
if (checkBlocked && blockedUsers.indexOf(user) >= 0) {
|
if (checkBlocked && blockedUsers.indexOf(user) >= 0) {
|
||||||
return callback(new Error(util.format(_('Blocked email address "%s"'), address)));
|
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) {
|
function getMessageLinks(serviceUrl, campaign, list, subscription) {
|
||||||
return {
|
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_PREFERENCES: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid),
|
||||||
LINK_BROWSER: urllib.resolve(serviceUrl, '/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid),
|
LINK_BROWSER: urllib.resolve(serviceUrl, '/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid),
|
||||||
CAMPAIGN_ID: campaign.cid,
|
CAMPAIGN_ID: campaign.cid,
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"schemaVersion": 28
|
"schemaVersion": 29
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
"sqldumptest": "NODE_ENV=test DUMP_NAME_SUFFIX=-test npm run sqldump",
|
"sqldumptest": "NODE_ENV=test DUMP_NAME_SUFFIX=-test npm run sqldump",
|
||||||
"sqlresettest": "NODE_ENV=test npm run sqldrop && NODE_ENV=test npm run sqlinit",
|
"sqlresettest": "NODE_ENV=test npm run sqldrop && NODE_ENV=test npm run sqlinit",
|
||||||
"starttest": "NODE_ENV=test node index.js",
|
"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"
|
"e2e": "npm run sqlresettest && npm run _e2e"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -41,7 +41,8 @@
|
||||||
"jsxgettext-andris": "^0.9.0-patch.1",
|
"jsxgettext-andris": "^0.9.0-patch.1",
|
||||||
"mocha": "^3.3.0",
|
"mocha": "^3.3.0",
|
||||||
"phantomjs": "^2.1.7",
|
"phantomjs": "^2.1.7",
|
||||||
"selenium-webdriver": "^3.4.0"
|
"selenium-webdriver": "^3.4.0",
|
||||||
|
"url-pattern": "^1.0.3"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"posix": "^4.1.1"
|
"posix": "^4.1.1"
|
||||||
|
@ -69,6 +70,7 @@
|
||||||
"faker": "^4.1.0",
|
"faker": "^4.1.0",
|
||||||
"feedparser": "^2.1.0",
|
"feedparser": "^2.1.0",
|
||||||
"file-type": "^4.1.0",
|
"file-type": "^4.1.0",
|
||||||
|
"fs-extra": "^3.0.1",
|
||||||
"geoip-ultralight": "^0.1.5",
|
"geoip-ultralight": "^0.1.5",
|
||||||
"gettext-parser": "^1.2.2",
|
"gettext-parser": "^1.2.2",
|
||||||
"gm": "^1.23.0",
|
"gm": "^1.23.0",
|
||||||
|
|
|
@ -5,10 +5,12 @@ let lists = require('../lib/models/lists');
|
||||||
let fields = require('../lib/models/fields');
|
let fields = require('../lib/models/fields');
|
||||||
let blacklist = require('../lib/models/blacklist');
|
let blacklist = require('../lib/models/blacklist');
|
||||||
let subscriptions = require('../lib/models/subscriptions');
|
let subscriptions = require('../lib/models/subscriptions');
|
||||||
|
let confirmations = require('../lib/models/confirmations');
|
||||||
let tools = require('../lib/tools');
|
let tools = require('../lib/tools');
|
||||||
let express = require('express');
|
let express = require('express');
|
||||||
let log = require('npmlog');
|
let log = require('npmlog');
|
||||||
let router = new express.Router();
|
let router = new express.Router();
|
||||||
|
let mailHelpers = require('../lib/subscription-mail-helpers');
|
||||||
|
|
||||||
router.all('/*', (req, res, next) => {
|
router.all('/*', (req, res, next) => {
|
||||||
if (!req.query.access_token) {
|
if (!req.query.access_token) {
|
||||||
|
@ -123,7 +125,7 @@ router.post('/subscribe/:listId', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^(yes|true|1)$/i.test(input.REQUIRE_CONFIRMATION)) {
|
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) {
|
if (err) {
|
||||||
log.error('API', err);
|
log.error('API', err);
|
||||||
res.status(500);
|
res.status(500);
|
||||||
|
@ -132,11 +134,23 @@ router.post('/subscribe/:listId', (req, res) => {
|
||||||
data: []
|
data: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.status(200);
|
|
||||||
res.json({
|
mailHelpers.sendConfirmSubscription(list, input.EMAIL, confirmCid, subscription, (err) => {
|
||||||
data: {
|
if (err) {
|
||||||
id: cid
|
log.error('API', err);
|
||||||
|
res.status(500);
|
||||||
|
return res.json({
|
||||||
|
error: err.message || err,
|
||||||
|
data: []
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.status(200);
|
||||||
|
res.json({
|
||||||
|
data: {
|
||||||
|
id: confirmCid
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
@ -189,7 +203,8 @@ router.post('/unsubscribe/:listId', (req, res) => {
|
||||||
data: []
|
data: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
subscriptions.unsubscribe(list.id, input.EMAIL, false, (err, subscription) => {
|
|
||||||
|
subscriptions.getByEmail(list.id, input.EMAIL, (err, subscription) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
res.status(500);
|
res.status(500);
|
||||||
return res.json({
|
return res.json({
|
||||||
|
@ -197,12 +212,30 @@ router.post('/unsubscribe/:listId', (req, res) => {
|
||||||
data: []
|
data: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.status(200);
|
|
||||||
res.json({
|
if (!subscription) {
|
||||||
data: {
|
res.status(404);
|
||||||
id: subscription.id,
|
return res.json({
|
||||||
unsubscribed: true
|
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({
|
||||||
|
error: err.message || err,
|
||||||
|
data: []
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
res.status(200);
|
||||||
|
res.json({
|
||||||
|
data: {
|
||||||
|
id: subscription.id,
|
||||||
|
unsubscribed: true
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -161,22 +161,32 @@ router.get('/:list/edit/:form', passport.csrfProtection, (req, res) => {
|
||||||
type: 'mjml',
|
type: 'mjml',
|
||||||
help: helpMjmlGeneral
|
help: helpMjmlGeneral
|
||||||
}, {
|
}, {
|
||||||
name: 'web_confirm_notice',
|
name: 'web_confirm_subscription_notice',
|
||||||
label: _('Web - Confirm Notice'),
|
label: _('Web - Confirm Subscription Notice'),
|
||||||
type: 'mjml',
|
type: 'mjml',
|
||||||
help: helpMjmlGeneral
|
help: helpMjmlGeneral
|
||||||
}, {
|
}, {
|
||||||
name: 'mail_confirm_html',
|
name: 'mail_confirm_subscription_html',
|
||||||
label: _('Mail - Confirm Subscription (MJML)'),
|
label: _('Mail - Confirm Subscription (MJML)'),
|
||||||
type: 'mjml',
|
type: 'mjml',
|
||||||
help: helpMjmlGeneral
|
help: helpMjmlGeneral
|
||||||
}, {
|
}, {
|
||||||
name: 'mail_confirm_text',
|
name: 'mail_confirm_subscription_text',
|
||||||
label: _('Mail - Confirm Subscription (Text)'),
|
label: _('Mail - Confirm Subscription (Text)'),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
help: helpEmailText
|
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'),
|
label: _('Web - Subscribed Notice'),
|
||||||
type: 'mjml',
|
type: 'mjml',
|
||||||
help: helpMjmlGeneral
|
help: helpMjmlGeneral
|
||||||
|
@ -217,20 +227,50 @@ router.get('/:list/edit/:form', passport.csrfProtection, (req, res) => {
|
||||||
type: 'mjml',
|
type: 'mjml',
|
||||||
help: helpMjmlGeneral
|
help: helpMjmlGeneral
|
||||||
}, {
|
}, {
|
||||||
name: 'web_unsubscribe_notice',
|
name: 'web_confirm_unsubscription_notice',
|
||||||
label: _('Web - Unsubscribe Notice'),
|
label: _('Web - Confirm Unsubscription Notice'),
|
||||||
type: 'mjml',
|
type: 'mjml',
|
||||||
help: helpMjmlGeneral
|
help: helpMjmlGeneral
|
||||||
}, {
|
}, {
|
||||||
name: 'mail_unsubscribe_confirmed_html',
|
name: 'mail_confirm_unsubscription_html',
|
||||||
label: _('Mail - Unsubscribe Confirmed (MJML)'),
|
label: _('Mail - Confirm Unsubscription (MJML)'),
|
||||||
type: 'mjml',
|
type: 'mjml',
|
||||||
help: helpMjmlGeneral
|
help: helpMjmlGeneral
|
||||||
}, {
|
}, {
|
||||||
name: 'mail_unsubscribe_confirmed_text',
|
name: 'mail_confirm_unsubscription_text',
|
||||||
label: _('Mail - Unsubscribe Confirmed (Text)'),
|
label: _('Mail - Confirm Unsubscription (Text)'),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
help: helpEmailText
|
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
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
@ -71,6 +71,8 @@ router.get('/create', passport.csrfProtection, (req, res) => {
|
||||||
data.publicSubscribe = true;
|
data.publicSubscribe = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.unsubscriptionModeOptions = getUnsubscriptionModeOptions(data.unsubscriptionMode || lists.UnsubscriptionMode.ONE_STEP);
|
||||||
|
|
||||||
res.render('lists/create', data);
|
res.render('lists/create', data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -103,6 +105,8 @@ router.get('/edit/:id', passport.csrfProtection, (req, res) => {
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
list.unsubscriptionModeOptions = getUnsubscriptionModeOptions(list.unsubscriptionMode);
|
||||||
|
|
||||||
list.csrfToken = req.csrfToken();
|
list.csrfToken = req.csrfToken();
|
||||||
res.render('lists/edit', list);
|
res.render('lists/edit', list);
|
||||||
});
|
});
|
||||||
|
@ -447,7 +451,7 @@ router.post('/subscription/unsubscribe', passport.parseForm, passport.csrfProtec
|
||||||
return res.redirect('/lists/view/' + list.id);
|
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) {
|
if (err) {
|
||||||
req.flash('danger', err && err.message || err || _('Could not unsubscribe user'));
|
req.flash('danger', err && err.message || err || _('Could not unsubscribe user'));
|
||||||
return res.redirect('/lists/subscription/' + list.id + '/edit/' + subscription.cid);
|
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;
|
module.exports = router;
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -419,7 +419,7 @@ function formatMessage(message, callback) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
list: {
|
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),
|
subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject),
|
||||||
html: renderedHtml,
|
html: renderedHtml,
|
||||||
|
|
|
@ -2,10 +2,30 @@
|
||||||
# Define incrementing schema version number
|
# Define incrementing schema version number
|
||||||
SET @schema_version = '28';
|
SET @schema_version = '28';
|
||||||
|
|
||||||
# Rename column tracking_disabled
|
# Add unsubscription mode field to lists
|
||||||
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;
|
ALTER TABLE `lists` ADD COLUMN `unsubscription_mode` int(11) unsigned DEFAULT 0 NOT NULL AFTER `public_subscribe`;
|
||||||
UPDATE `campaigns` SET `open_tracking_disabled` = `tracking_disabled`, `click_tracking_disabled` = `tracking_disabled`;
|
|
||||||
ALTER TABLE `campaigns` DROP COLUMN `tracking_disabled`;
|
# 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
|
# Footer section
|
||||||
LOCK TABLES `settings` WRITE;
|
LOCK TABLES `settings` WRITE;
|
||||||
|
|
13
setup/sql/upgrade-00029.sql
Normal file
13
setup/sql/upgrade-00029.sql
Normal 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;
|
|
@ -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;
|
|
|
@ -1,36 +1,30 @@
|
||||||
'use strict';
|
'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;
|
global.USE_SHARED_DRIVER = true;
|
||||||
|
|
||||||
const driver = require('./helpers/driver');
|
|
||||||
const only = 'only';
|
const only = 'only';
|
||||||
const skip = 'skip';
|
const skip = 'skip';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let tests = [
|
let tests = [
|
||||||
['tests/login'],
|
'login',
|
||||||
['tests/subscription']
|
'subscription'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
tests = tests.map(testSpec => (testSpec.constructor === Array ? testSpec : [testSpec]));
|
||||||
|
tests = tests.filter(testSpec => testSpec[1] !== skip);
|
||||||
tests = tests.filter(t => t[1] !== skip);
|
if (tests.some(testSpec => testSpec[1] === only)) {
|
||||||
|
tests = tests.filter(testSpec => testSpec[1] === only);
|
||||||
if (tests.some(t => t[1] === only)) {
|
|
||||||
tests = tests.filter(t => t[1] === only);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('e2e', function() {
|
for (const testSpec of tests) {
|
||||||
this.timeout(10000);
|
const testPath = path.join(__dirname, 'tests', testSpec[0] + '.js');
|
||||||
|
mocha.addFile(testPath);
|
||||||
|
}
|
||||||
|
|
||||||
tests.forEach(t => {
|
mocha.run(failures => {
|
||||||
describe(t[0], () => {
|
process.exit(failures); // exit with non-zero status if there were failures
|
||||||
require('./' + t[0]); // eslint-disable-line global-require
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => driver.originalQuit());
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,6 +5,7 @@ const config = require('config');
|
||||||
module.exports = {
|
module.exports = {
|
||||||
app: config,
|
app: config,
|
||||||
baseUrl: 'http://localhost:' + config.www.port,
|
baseUrl: 'http://localhost:' + config.www.port,
|
||||||
|
mailUrl: 'http://localhost:' + config.testserver.mailboxserverport,
|
||||||
users: {
|
users: {
|
||||||
admin: {
|
admin: {
|
||||||
username: 'admin',
|
username: 'admin',
|
19
test/e2e/lib/mail.js
Normal file
19
test/e2e/lib/mail.js
Normal 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
217
test/e2e/lib/mocha-e2e.js
Normal 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
122
test/e2e/lib/page.js
Normal 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
35
test/e2e/lib/semaphore.js
Normal 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
77
test/e2e/lib/web.js
Normal 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);
|
|
@ -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]);
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,11 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const page = require('./page');
|
const web = require('../lib/web');
|
||||||
|
|
||||||
module.exports = driver => Object.assign(page(driver), {
|
module.exports = web({
|
||||||
url: '/',
|
url: '/'
|
||||||
elementToWaitFor: 'body',
|
|
||||||
elements: {
|
|
||||||
body: 'body.page--home'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,84 +1,132 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const config = require('../helpers/config');
|
const config = require('../lib/config');
|
||||||
const page = require('./page');
|
const web = require('../lib/web');
|
||||||
|
const mail = require('../lib/mail');
|
||||||
|
|
||||||
const web = {
|
module.exports = list => ({
|
||||||
enterEmail(value) {
|
|
||||||
this.element('emailInput').clear();
|
|
||||||
return this.element('emailInput').sendKeys(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mail = {
|
webSubscribe: web({
|
||||||
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, {
|
|
||||||
url: `/subscription/${list.cid}`,
|
url: `/subscription/${list.cid}`,
|
||||||
elementToWaitFor: 'form',
|
elementsToWaitFor: ['form'],
|
||||||
|
textsToWaitFor: ['Subscribe to list'],
|
||||||
elements: {
|
elements: {
|
||||||
form: `form[action="/subscription/${list.cid}/subscribe"]`,
|
form: `form[action="/subscription/${list.cid}/subscribe"]`,
|
||||||
emailInput: '#main-form input[name="email"]',
|
emailInput: '#main-form input[name="email"]',
|
||||||
|
firstNameInput: '#main-form input[name="first-name"]',
|
||||||
|
lastNameInput: '#main-form input[name="last-name"]',
|
||||||
submitButton: 'a[href="#submit"]'
|
submitButton: 'a[href="#submit"]'
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
webConfirmSubscriptionNotice: Object.assign(page(driver), web, {
|
webConfirmSubscriptionNotice: web({
|
||||||
url: `/subscription/${list.cid}/confirm-notice`,
|
url: `/subscription/${list.cid}/confirm-subscription-notice`,
|
||||||
elementToWaitFor: 'homepageButton',
|
textsToWaitFor: ['We need to confirm your email address']
|
||||||
|
}),
|
||||||
|
|
||||||
|
mailConfirmSubscription: mail({
|
||||||
|
elementsToWaitFor: ['confirmLink'],
|
||||||
|
textsToWaitFor: ['Please Confirm Subscription'],
|
||||||
elements: {
|
elements: {
|
||||||
homepageButton: `a[href="${config.settings['default-homepage']}"]`
|
confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/subscribe/"]`
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
mailConfirmSubscription: Object.assign(page(driver), mail, {
|
mailAlreadySubscribed: mail({
|
||||||
elementToWaitFor: 'confirmLink',
|
elementsToWaitFor: ['unsubscribeLink'],
|
||||||
|
textsToWaitFor: ['Email address already registered'],
|
||||||
elements: {
|
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, {
|
webSubscribedNotice: web({
|
||||||
elementToWaitFor: 'homepageButton',
|
url: `/subscription/${list.cid}/subscribed-notice`,
|
||||||
|
textsToWaitFor: ['Subscription Confirmed']
|
||||||
|
}),
|
||||||
|
|
||||||
|
mailSubscriptionConfirmed: mail({
|
||||||
|
elementsToWaitFor: ['unsubscribeLink'],
|
||||||
|
textsToWaitFor: ['Subscription Confirmed'],
|
||||||
elements: {
|
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, {
|
webManage: web({
|
||||||
elementToWaitFor: 'unsubscribeLink',
|
url: `/subscription/${list.cid}/manage/:ucid`,
|
||||||
|
elementsToWaitFor: ['form'],
|
||||||
|
textsToWaitFor: ['Update Your Preferences'],
|
||||||
elements: {
|
elements: {
|
||||||
unsubscribeLink: 'a[href*="/unsubscribe/"]',
|
form: `form[action="/subscription/${list.cid}/manage"]`,
|
||||||
manageLink: 'a[href*="/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, {
|
webManageAddress: web({
|
||||||
elementToWaitFor: 'submitButton',
|
url: `/subscription/${list.cid}/manage-address/:ucid`,
|
||||||
|
elementsToWaitFor: ['form'],
|
||||||
|
textsToWaitFor: ['Update Your Email Address'],
|
||||||
elements: {
|
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, {
|
mailConfirmAddressChange: mail({
|
||||||
elementToWaitFor: 'homepageButton',
|
elementsToWaitFor: ['confirmLink'],
|
||||||
|
textsToWaitFor: ['Please Confirm Subscription Address Change'],
|
||||||
elements: {
|
elements: {
|
||||||
homepageButton: 'a[href^="https://mailtrain.org"]'
|
confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/change-address/"]`
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
mailUnsubscriptionConfirmed: Object.assign(page(driver), mail, {
|
webUpdatedNotice: web({
|
||||||
elementToWaitFor: 'resubscribeLink',
|
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: {
|
elements: {
|
||||||
resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]`
|
resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]`
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
|
||||||
|
/*
|
||||||
|
webUnsubscribe: web({ // FIXME
|
||||||
|
elementsToWaitFor: ['submitButton'],
|
||||||
|
elements: {
|
||||||
|
submitButton: 'a[href="#submit"]'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,32 +1,29 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const page = require('./page');
|
const web = require('../lib/web');
|
||||||
|
|
||||||
module.exports = driver => ({
|
module.exports = {
|
||||||
|
login: web({
|
||||||
login: Object.assign(page(driver), {
|
|
||||||
url: '/users/login',
|
url: '/users/login',
|
||||||
elementToWaitFor: 'submitButton',
|
elementsToWaitFor: ['submitButton'],
|
||||||
elements: {
|
elements: {
|
||||||
usernameInput: 'form[action="/users/login"] input[name="username"]',
|
usernameInput: 'form[action="/users/login"] input[name="username"]',
|
||||||
passwordInput: 'form[action="/users/login"] input[name="password"]',
|
passwordInput: 'form[action="/users/login"] input[name="password"]',
|
||||||
submitButton: 'form[action="/users/login"] [type=submit]'
|
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',
|
url: '/users/account',
|
||||||
elementToWaitFor: 'emailInput',
|
elementsToWaitFor: ['form'],
|
||||||
elements: {
|
elements: {
|
||||||
|
form: `form[action="/users/account"]`,
|
||||||
emailInput: 'form[action="/users/account"] input[name="email"]'
|
emailInput: 'form[action="/users/account"] input[name="email"]'
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
};
|
||||||
});
|
|
|
@ -1,57 +1,68 @@
|
||||||
'use strict';
|
'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 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() {
|
const page = require('../page-objects/user');
|
||||||
this.timeout(10000);
|
const home = require('../page-objects/home');
|
||||||
|
|
||||||
|
suite('Login use-cases', function() {
|
||||||
before(() => driver.manage().deleteAllCookies());
|
before(() => driver.manage().deleteAllCookies());
|
||||||
|
|
||||||
it('can access home page', async () => {
|
test('User can access home page', async () => {
|
||||||
await home.navigate();
|
await home.navigate();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can not access restricted content', async () => {
|
test('Anonymous user cannot access restricted content', async () => {
|
||||||
driver.navigate().to(config.baseUrl + '/settings');
|
await driver.navigate().to(config.baseUrl + '/settings');
|
||||||
flash.waitUntilVisible();
|
await page.login.waitUntilVisible();
|
||||||
expect(await flash.getText()).to.contain('Need to be logged in to access restricted content');
|
await page.login.waitForFlash();
|
||||||
await flash.clear();
|
expect(await page.login.getFlash()).to.contain('Need to be logged in to access restricted content');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can not login with false credentials', async () => {
|
useCase('Login (invalid credential)', async () => {
|
||||||
login.enterUsername(config.users.admin.username);
|
await step('User navigates to the login page.', async () => {
|
||||||
login.enterPassword('invalid');
|
await page.login.navigate();
|
||||||
login.submit();
|
});
|
||||||
flash.waitUntilVisible();
|
|
||||||
expect(await flash.getText()).to.contain('Incorrect username or password');
|
await step('User fills in the user name and incorrect password.', async () => {
|
||||||
await flash.clear();
|
await page.login.setValue('usernameInput', config.users.admin.username);
|
||||||
|
await page.login.setValue('passwordInput', 'invalid');
|
||||||
|
await page.login.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 login as admin', async () => {
|
useCase('Login and logout', async () => {
|
||||||
login.enterUsername(config.users.admin.username);
|
await step('User navigates to the login page.', async () => {
|
||||||
login.enterPassword(config.users.admin.password);
|
await page.login.navigate();
|
||||||
login.submit();
|
});
|
||||||
flash.waitUntilVisible();
|
|
||||||
expect(await flash.getText()).to.contain('Logged in as admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can access account page as admin', async () => {
|
await step('User fills in the user name and password.', async () => {
|
||||||
await account.navigate();
|
await page.login.setValue('usernameInput', config.users.admin.username);
|
||||||
});
|
await page.login.setValue('passwordInput', config.users.admin.password);
|
||||||
|
await page.login.submit();
|
||||||
|
});
|
||||||
|
|
||||||
it('can logout', async () => {
|
await step('System shows the home page and a flash notice that user has been logged in.', async () => {
|
||||||
driver.navigate().to(config.baseUrl + '/users/logout');
|
await home.waitUntilVisibleAfterRefresh();
|
||||||
flash.waitUntilVisible();
|
await home.waitForFlash();
|
||||||
expect(await flash.getText()).to.contain('logged out');
|
expect(await home.getFlash()).to.contain('Logged in as admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => driver.quit());
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,101 +1,248 @@
|
||||||
'use strict';
|
'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 shortid = require('shortid');
|
||||||
const expect = require('chai').expect;
|
const expect = require('chai').expect;
|
||||||
const driver = require('../helpers/driver');
|
|
||||||
|
|
||||||
const page = require('../page-objects/page')(driver);
|
const page = require('../page-objects/subscription')(config.lists.one);
|
||||||
const flash = require('../page-objects/flash')(driver);
|
|
||||||
|
|
||||||
const {
|
function generateEmail() {
|
||||||
webSubscribe,
|
return 'keep.' + shortid.generate() + '@mailtrain.org';
|
||||||
webConfirmSubscriptionNotice,
|
}
|
||||||
mailConfirmSubscription,
|
|
||||||
webSubscribedNotice,
|
|
||||||
mailSubscriptionConfirmed,
|
|
||||||
webUnsubscribe,
|
|
||||||
webUnsubscribedNotice,
|
|
||||||
mailUnsubscriptionConfirmed
|
|
||||||
} = require('../page-objects/subscription')(driver, config.lists.one);
|
|
||||||
|
|
||||||
const testuser = {
|
async function subscribe(subscription) {
|
||||||
email: 'keep.' + shortid.generate() + '@mailtrain.org'
|
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() {
|
if (subscription.firstName) {
|
||||||
this.timeout(10000);
|
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());
|
before(() => driver.manage().deleteAllCookies());
|
||||||
|
|
||||||
it('visits web-subscribe', async () => {
|
useCase('Subscription to a public list (main scenario)', async () => {
|
||||||
await webSubscribe.navigate();
|
await subscribe({
|
||||||
|
email: generateEmail()
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submits invalid email (error)', async () => {
|
useCase('Subscription to a public list (invalid email)', async () => {
|
||||||
webSubscribe.enterEmail('foo@bar.nope');
|
await step('User navigates to list subscribe page', async () => {
|
||||||
webSubscribe.submit();
|
await page.webSubscribe.navigate();
|
||||||
flash.waitUntilVisible();
|
});
|
||||||
expect(await flash.getText()).to.contain('Invalid email address');
|
|
||||||
|
await step('User submits an invalid email.', async () => {
|
||||||
|
await page.webSubscribe.setValue('emailInput', 'foo@bar.nope');
|
||||||
|
await page.webSubscribe.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
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('submits valid email', async () => {
|
useCase('Subscription to a public list (email already registered)', async () => {
|
||||||
webSubscribe.enterEmail(testuser.email);
|
const subscription = await subscriptionExistsPrecondition({
|
||||||
await webSubscribe.submit();
|
email: generateEmail()
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User navigates to list subscribe page', async () => {
|
||||||
|
await page.webSubscribe.navigate();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('User submits the email which has been already registered.', async () => {
|
||||||
|
await page.webSubscribe.setValue('emailInput', subscription.email);
|
||||||
|
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 informing that the address has been already registered.', async () => {
|
||||||
|
await page.mailAlreadySubscribed.fetchMail(subscription.email);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sees web-confirm-subscription-notice', async () => {
|
useCase('Subscription to a non-public list');
|
||||||
webConfirmSubscriptionNotice.waitUntilVisible();
|
|
||||||
expect(await page.containsText('Almost Finished')).to.be.true;
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('receives mail-confirm-subscription', async () => {
|
useCase('Change email', async () => {
|
||||||
mailConfirmSubscription.navigate(testuser.email);
|
const subscription = await subscriptionExistsPrecondition({
|
||||||
expect(await page.containsText('Please Confirm Subscription')).to.be.true;
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicks confirm subscription', async () => {
|
useCase('Unsubscription from list #1 (one-step, no form).', async () => {
|
||||||
await mailConfirmSubscription.click('confirmLink');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sees web-subscribed-notice', async () => {
|
useCase('Unsubscription from list #2 (one-step, with form).');
|
||||||
webSubscribedNotice.waitUntilVisible();
|
|
||||||
expect(await page.containsText('Subscription Confirmed')).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('receives mail-subscription-confirmed', async () => {
|
useCase('Unsubscription from list #3 (two-step, no form).');
|
||||||
mailSubscriptionConfirmed.navigate(testuser.email);
|
|
||||||
expect(await page.containsText('Subscription Confirmed')).to.be.true;
|
useCase('Unsubscription from list #4 (two-step, with form).');
|
||||||
});
|
|
||||||
});
|
useCase('Unsubscription from list #5 (manual unsubscribe).');
|
||||||
|
|
||||||
describe('unsubscribe (list one)', function() {
|
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.
|
||||||
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());
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -26,11 +26,27 @@
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<div class="col-sm-offset-2">
|
<div class="form-group">
|
||||||
<div class="checkbox">
|
<label for="default_form" class="col-sm-2 control-label">{{#translate}}Subscription{{/translate}}</label>
|
||||||
<label>
|
<div class="col-sm-10">
|
||||||
<input type="checkbox" name="public_subscribe" value="1" {{#if publicSubscribe}} checked {{/if}}> {{#translate}}Allow public users to subscribe themselves{{/translate}}
|
<div class="checkbox">
|
||||||
</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -56,11 +56,27 @@
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<div class="col-sm-offset-2">
|
<div class="form-group">
|
||||||
<div class="checkbox">
|
<label for="default_form" class="col-sm-2 control-label">{{#translate}}Subscription{{/translate}}</label>
|
||||||
<label>
|
<div class="col-sm-10">
|
||||||
<input type="checkbox" name="public_subscribe" value="1" {{#if publicSubscribe}} checked {{/if}}> {{#translate}}Allow public users to subscribe themselves{{/translate}}
|
<div class="checkbox">
|
||||||
</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -46,12 +46,20 @@
|
||||||
<p>
|
<p>
|
||||||
<a href="/subscription/{{list.cid}}?fid={{form.id}}" target="_blank">{{#translate}}Subscribe{{/translate}}</a>
|
<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}}/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}}
|
{{#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>
|
<a href="/subscription/{{list.cid}}/manage/{{testUsers.0.cid}}?fid={{form.id}}" target="_blank">{{#translate}}Manage{{/translate}}</a>
|
||||||
|
|
|
|
||||||
|
|
|
@ -68,7 +68,7 @@
|
||||||
<form class="form-inline" method="post" action="/lists/edit?next=%2Fforms%2F{{list.id}}">
|
<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="_csrf" value="{{csrfToken}}">
|
||||||
<input type="hidden" name="id" value="{{list.id}}" />
|
<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">
|
<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>
|
<label for="default_form" class="control-label" style="color: #666; font-weight: normal;">{{#translate}}The default form for this list is:{{/translate}}</label>
|
||||||
|
|
24
views/subscription/mail-already-subscribed-html.mjml.hbs
Normal file
24
views/subscription/mail-already-subscribed-html.mjml.hbs
Normal 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>
|
18
views/subscription/mail-already-subscribed-text.hbs
Normal file
18
views/subscription/mail-already-subscribed-text.hbs
Normal 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}}}
|
17
views/subscription/mail-confirm-address-change-html.mjml.hbs
Normal file
17
views/subscription/mail-confirm-address-change-html.mjml.hbs
Normal 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>
|
10
views/subscription/mail-confirm-address-change-text.hbs
Normal file
10
views/subscription/mail-confirm-address-change-text.hbs
Normal 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}}}
|
17
views/subscription/mail-confirm-unsubscription-html.mjml.hbs
Normal file
17
views/subscription/mail-confirm-unsubscription-html.mjml.hbs
Normal 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>
|
10
views/subscription/mail-confirm-unsubscription-text.hbs
Normal file
10
views/subscription/mail-confirm-unsubscription-text.hbs
Normal 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}}}
|
|
@ -1,6 +1,6 @@
|
||||||
{{#each customFields}}
|
{{#each customFields}}
|
||||||
|
|
||||||
{{#if typeSubsciptionEmail}}
|
{{#if typeSubscriptionEmail}}
|
||||||
<div class="form-group email">
|
<div class="form-group email">
|
||||||
<label for="email">{{#translate}}Email Address{{/translate}}</label>
|
<label for="email">{{#translate}}Email Address{{/translate}}</label>
|
||||||
{{#if ../isManagePreferences}}
|
{{#if ../isManagePreferences}}
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
<input type="hidden" class="tz-detect" name="tz" id="tz" value="{{tz}}">
|
<input type="hidden" class="tz-detect" name="tz" id="tz" value="{{tz}}">
|
||||||
<input type="hidden" name="address" value="">
|
<input type="hidden" name="address" value="">
|
||||||
<input type="hidden" name="sub" id="sub" value="">
|
<input type="hidden" name="sub" id="sub" value="">
|
||||||
|
<input type="hidden" name="ucid" value="{{ucid}}">
|
||||||
|
|
||||||
{{> subscription_custom_fields}}
|
{{> subscription_custom_fields}}
|
||||||
|
|
||||||
|
|
|
@ -1,20 +1,13 @@
|
||||||
<form method="post" id="main-form" action="/subscription/{{lcid}}/unsubscribe">
|
<form method="post" id="main-form" action="/subscription/{{lcid}}/unsubscribe">
|
||||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||||
<input type="hidden" name="campaign" value="{{campaign}}">
|
<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">
|
<div class="form-group">
|
||||||
<label for="email">{{#translate}}Email address{{/translate}}</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Unsubscribe{{/translate}}</button>
|
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Unsubscribe{{/translate}}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{#if email}}
|
|
||||||
{{#if autosubmit}}
|
|
||||||
<script>
|
|
||||||
document.getElementById('main-form').submit();
|
|
||||||
</script>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
|
|
|
@ -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>
|
13
views/subscription/web-manual-unsubscribe-notice.mjml.hbs
Normal file
13
views/subscription/web-manual-unsubscribe-notice.mjml.hbs
Normal 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>
|
|
@ -3,9 +3,6 @@
|
||||||
<mj-text mj-class="h3">
|
<mj-text mj-class="h3">
|
||||||
{{#translate}}Unsubscribe{{/translate}}
|
{{#translate}}Unsubscribe{{/translate}}
|
||||||
</mj-text>
|
</mj-text>
|
||||||
<mj-text mj-class="p">
|
|
||||||
{{#translate}}Enter your email address to unsubscribe from:{{/translate}} {{title}}
|
|
||||||
</mj-text>
|
|
||||||
<mj-text>
|
<mj-text>
|
||||||
{{> subscription_unsubscribe_form}}
|
{{> subscription_unsubscribe_form}}
|
||||||
</mj-text>
|
</mj-text>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue