diff --git a/.gitignore b/.gitignore index c7687df8..14c576b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +/.idea +/last-failed-e2e-test.* + node_modules npm-debug.log .DS_Store diff --git a/app.js b/app.js index 1143df71..c6578e60 100644 --- a/app.js +++ b/app.js @@ -184,8 +184,9 @@ app.use((req, res, next) => { res.locals.customScripts = config.customscripts || []; let bodyClasses = []; - app.get('env') === 'test' && bodyClasses.push('page--' + (req.path.substring(1).replace(/\//g, '--') || 'home')); - req.user && bodyClasses.push('logged-in user-' + req.user.username); + if (req.user) { + bodyClasses.push('logged-in user-' + req.user.username); + } res.locals.bodyClass = bodyClasses.join(' '); settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => { diff --git a/lib/helpers.js b/lib/helpers.js index 597c36d8..786e3473 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -22,7 +22,8 @@ module.exports = { injectCustomFormData, injectCustomFormTemplates, filterCustomFields, - getMjmlTemplate + getMjmlTemplate, + rollbackAndReleaseConnection }; function getDefaultMergeTags(callback) { @@ -124,7 +125,7 @@ function filterCustomFields(customFieldsIn = [], fieldIds = [], method = 'includ id: 'email', name: 'Email Address', type: 'Email', - typeSubsciptionEmail: true + typeSubscriptionEmail: true }, { id: 'firstname', name: 'First Name', @@ -285,3 +286,10 @@ function captureFlashMessages(req, res, callback) { callback(null, flash); }); } + +function rollbackAndReleaseConnection(connection, callback) { + connection.rollback(() => { + connection.release(); + return callback(); + }); +} diff --git a/lib/models/campaigns.js b/lib/models/campaigns.js index 1291cd78..bec512bb 100644 --- a/lib/models/campaigns.js +++ b/lib/models/campaigns.js @@ -1054,13 +1054,13 @@ module.exports.updateMessage = (message, status, updateSubscription, callback) = let statusCode; if (status === 'unsubscribed') { - statusCode = 2; - } - if (status === 'bounced') { - statusCode = 3; - } - if (status === 'complained') { - statusCode = 4; + statusCode = subscriptions.Status.UNSUBSCRIBED; + } else if (status === 'bounced') { + statusCode = subscriptions.Status.BOUNCED; + } else if (status === 'complained') { + statusCode = subscriptions.Status.COMPLAINED; + } else { + return callback(new Error(_('Unrecognized message status'))); } let query = 'UPDATE `campaigns` SET `' + status + '`=`' + status + '`+1 WHERE id=? LIMIT 1'; @@ -1074,7 +1074,7 @@ module.exports.updateMessage = (message, status, updateSubscription, callback) = } if (updateSubscription) { - subscriptions.changeStatus(message.subscription, message.list, statusCode === 2 ? message.campaign : false, statusCode, callback); + subscriptions.changeStatus(message.list, message.subscription, statusCode === subscriptions.Status.UNSUBSCRIBED ? message.campaign : false, statusCode, callback); } else { return callback(null, true); } diff --git a/lib/models/confirmations.js b/lib/models/confirmations.js new file mode 100644 index 00000000..9cbceee6 --- /dev/null +++ b/lib/models/confirmations.js @@ -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); + }); + }); + }); + }); + }); +}; diff --git a/lib/models/forms.js b/lib/models/forms.js index 940f5483..9550077b 100644 --- a/lib/models/forms.js +++ b/lib/models/forms.js @@ -14,22 +14,31 @@ let allowedKeys = [ 'fields_shown_on_manage', 'layout', 'form_input_style', - 'mail_confirm_html', - 'mail_confirm_text', + 'web_subscribe', + 'web_confirm_subscription_notice', + 'mail_confirm_subscription_html', + 'mail_confirm_subscription_text', + 'mail_already_subscribed_html', + 'mail_already_subscribed_text', + 'web_subscribed_notice', 'mail_subscription_confirmed_html', 'mail_subscription_confirmed_text', - 'mail_unsubscribe_confirmed_html', - 'mail_unsubscribe_confirmed_text', - 'web_confirm_notice', - 'web_manage_address', 'web_manage', - 'web_subscribe', - 'web_subscribed', - 'web_unsubscribe_notice', + 'web_manage_address', + 'web_updated_notice', 'web_unsubscribe', - 'web_updated_notice' + 'web_confirm_unsubscription_notice', + 'mail_confirm_unsubscription_html', + 'mail_confirm_unsubscription_text', + 'mail_confirm_address_change_html', + 'mail_confirm_address_change_text', + 'web_unsubscribed_notice', + 'mail_unsubscription_confirmed_html', + 'mail_unsubscription_confirmed_text', + 'web_manual_unsubscribe_notice' ]; + module.exports.list = (listId, callback) => { listId = Number(listId) || 0; diff --git a/lib/models/lists.js b/lib/models/lists.js index 28304722..839fefe1 100644 --- a/lib/models/lists.js +++ b/lib/models/lists.js @@ -7,7 +7,18 @@ let segments = require('./segments'); let _ = require('../translate')._; let tableHelpers = require('../table-helpers'); -let allowedKeys = ['description', 'default_form', 'public_subscribe']; +const UnsubscriptionMode = { + ONE_STEP: 0, + ONE_STEP_WITH_FORM: 1, + TWO_STEP: 2, + TWO_STEP_WITH_FORM: 3, + MANUAL: 4, + MAX: 5 +}; + +module.exports.UnsubscriptionMode = UnsubscriptionMode; + +let allowedKeys = ['description', 'default_form', 'public_subscribe', 'unsubscription_mode']; module.exports.list = (start, limit, callback) => { tableHelpers.list('lists', ['*'], 'name', null, start, limit, callback); @@ -99,6 +110,63 @@ module.exports.get = (id, callback) => { }); }; +module.exports.update = (id, updates, callback) => { + updates = updates || {}; + id = Number(id) || 0; + + if (id < 1) { + return callback(new Error(_('Missing List ID'))); + } + + const data = tools.convertKeys(updates); + + const keys = []; + const values = []; + + // The update can be only partial when executed from forms/:list + if (!data.customFormChangeOnly) { + data.publicSubscribe = data.publicSubscribe ? 1 : 0; + data.unsubscriptionMode = Number(data.unsubscriptionMode); + + let name = (data.name || '').toString().trim(); + + if (!name) { + return callback(new Error(_('List Name must be set'))); + } + + keys.push('name'); + values.push(name); + } + + Object.keys(data).forEach(key => { + let value = data[key].toString().trim(); + key = tools.toDbKey(key); + if (key === 'description') { + value = tools.purifyHTML(value); + } + if (allowedKeys.indexOf(key) >= 0) { + keys.push(key); + values.push(value); + } + }); + + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + values.push(id); + + connection.query('UPDATE lists SET ' + keys.map(key => key + '=?').join(', ') + ' WHERE id=? LIMIT 1', values, (err, result) => { + connection.release(); + if (err) { + return callback(err); + } + return callback(null, result && result.affectedRows || false); + }); + }); +}; + module.exports.create = (list, callback) => { let data = tools.convertKeys(list); @@ -157,54 +225,6 @@ module.exports.create = (list, callback) => { }); }; -module.exports.update = (id, updates, callback) => { - updates = updates || {}; - id = Number(id) || 0; - - let data = tools.convertKeys(updates); - data.publicSubscribe = data.publicSubscribe ? 1 : 0; - - let name = (data.name || '').toString().trim(); - let keys = ['name']; - let values = [name]; - - if (id < 1) { - return callback(new Error(_('Missing List ID'))); - } - - if (!name) { - return callback(new Error(_('List Name must be set'))); - } - - Object.keys(data).forEach(key => { - let value = data[key].toString().trim(); - key = tools.toDbKey(key); - if (key === 'description') { - value = tools.purifyHTML(value); - } - if (allowedKeys.indexOf(key) >= 0) { - keys.push(key); - values.push(value); - } - }); - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - values.push(id); - - connection.query('UPDATE lists SET ' + keys.map(key => key + '=?').join(', ') + ' WHERE id=? LIMIT 1', values, (err, result) => { - connection.release(); - if (err) { - return callback(err); - } - return callback(null, result && result.affectedRows || false); - }); - }); -}; - module.exports.delete = (id, callback) => { id = Number(id) || 0; diff --git a/lib/models/reports.js b/lib/models/reports.js index 0c6a91f9..edfe665f 100644 --- a/lib/models/reports.js +++ b/lib/models/reports.js @@ -13,7 +13,8 @@ const ReportState = { SCHEDULED: 0, PROCESSING: 1, FINISHED: 2, - FAILED: 3 + FAILED: 3, + MAX: 4 }; module.exports.ReportState = ReportState; @@ -245,8 +246,8 @@ module.exports.getCampaignResults = (campaign, select, clause, callback) => { const query = 'SELECT ' + selFields.join(', ') + ' FROM `subscription__' + campaign.list + '` subscribers INNER JOIN `campaign__' + campaign.id + '` campaign on subscribers.id=campaign.subscription LEFT JOIN `campaign_tracker__' + campaign.id + '` tracker on subscribers.id=tracker.subscriber ' + clause; connection.query(query, (err, results) => { + connection.release(); if (err) { - connection.release(); return callback(err); } diff --git a/lib/models/settings.js b/lib/models/settings.js index 77b9f391..9655ebd7 100644 --- a/lib/models/settings.js +++ b/lib/models/settings.js @@ -15,6 +15,8 @@ function listValues(filter, callback) { filter = false; } + // TODO: It would be good to cache the settings. It feels awkward to always go to DB to retrieve something what is essentially a constant + filter = [].concat(filter || []).map(key => tools.toDbKey(key)); db.getConnection((err, connection) => { diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index 99d32be0..080acb5e 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -5,16 +5,20 @@ let shortid = require('shortid'); let tools = require('../tools'); let helpers = require('../helpers'); let fields = require('./fields'); -let geoip = require('geoip-ultralight'); let segments = require('./segments'); -let settings = require('./settings'); -let mailer = require('../mailer'); -let urllib = require('url'); -let log = require('npmlog'); let _ = require('../translate')._; -let util = require('util'); let tableHelpers = require('../table-helpers'); +const Status = { + SUBSCRIBED: 1, + UNSUBSCRIBED: 2, + BOUNCED: 3, + COMPLAINED: 4, + MAX: 5 +}; + +module.exports.Status = Status; + module.exports.list = (listId, start, limit, callback) => { listId = Number(listId) || 0; if (!listId) { @@ -88,197 +92,17 @@ module.exports.filter = (listId, request, columns, segmentId, callback) => { }; -module.exports.addConfirmation = (list, email, optInIp, data, callback) => { - let cid = shortid.generate(); - - tools.validateEmail(email, false, err => { - if (err) { - return callback(err); - } - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - let query = 'INSERT INTO confirmations (cid, list, email, opt_in_ip, data) VALUES (?,?,?,?,?)'; - connection.query(query, [cid, list.id, email, optInIp, JSON.stringify(data || {})], (err, result) => { - connection.release(); - if (err) { - return callback(err); - } - - if (!result || !result.affectedRows) { - return callback(null, false); - } - - fields.list(list.id, (err, fieldList) => { - if (err) { - return callback(err); - } - - let encryptionKeys = []; - fields.getRow(fieldList, data).forEach(field => { - if (field.type === 'gpg' && field.value) { - encryptionKeys.push(field.value.trim()); - } - }); - - settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl'], (err, configItems) => { - if (err) { - return callback(err); - } - - setImmediate(() => { - if (data._skip) { - log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data)); - return; - } - - let sendMail = (html, text) => { - mailer.sendMail({ - from: { - name: configItems.defaultFrom, - address: configItems.defaultAddress - }, - to: { - name: [].concat(data.firstName || []).concat(data.lastName || []).join(' '), - address: email - }, - subject: util.format(_('%s: Please Confirm Subscription'), list.name), - encryptionKeys - }, { - html, - text, - data: { - title: list.name, - contactAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - confirmUrl: urllib.resolve(configItems.serviceUrl, '/subscription/subscribe/' + cid) - } - }, err => { - if (err) { - log.error('Subscription', err); - } - }); - }; - - let text = { - template: 'subscription/mail-confirm-text.hbs' - }; - - let html = { - template: 'subscription/mail-confirm-html.mjml.hbs', - layout: 'subscription/layout.mjml.hbs', - type: 'mjml' - }; - - helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => { - if (err) { - return sendMail(html, text); - } - - sendMail(tmpl.html, tmpl.text); - }); - }); - return callback(null, cid); - }); - }); - }); - }); - }); -}; - -module.exports.subscribe = (cid, optInIp, callback) => { - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - let query = 'SELECT * FROM confirmations WHERE cid=? LIMIT 1'; - connection.query(query, [cid], (err, rows) => { - connection.release(); - if (err) { - return callback(err); - } - - if (!rows || !rows.length) { - return callback(null, false); - } - - let subscription; - let listId = rows[0].list; - let email = rows[0].email; - try { - subscription = JSON.parse(rows[0].data); - } catch (E) { - subscription = {}; - } - - if (subscription.action === 'update' && subscription.subscriber) { - // update email address instead of adding new - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - let query = 'UPDATE `subscription__' + listId + '` SET `email`=? WHERE `id`=? LIMIT 1'; - let args = [email, subscription.subscriber]; - connection.query(query, args, err => { - if (err) { - connection.release(); - return callback(err); - } - connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => { - connection.release(); - // reload full data from db in case it was an update, not insert - return module.exports.getById(listId, subscription.subscriber, callback); - }); - }); - }); - return; - } - - subscription.cid = cid; - subscription.list = listId; - subscription.email = email; - - let optInCountry = geoip.lookupCountry(optInIp) || null; - module.exports.insert(listId, { - email, - cid, - optInIp, - optInCountry, - status: 1 - }, subscription, (err, result) => { - if (err) { - return callback(err); - } - - if (!result.entryId) { - return callback(new Error(_('Could not save subscription'))); - } - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => { - connection.release(); - // reload full data from db in case it was an update, not insert - return module.exports.getById(listId, result.entryId, callback); - }); - }); - }); - }); - }); -}; - -module.exports.insert = (listId, meta, subscription, callback) => { +/* + Adds a new subscription. Returns error if a subscription with the same email address is already present and is not unsubscribed. + If it is unsubscribed, the existing subscription is changed based on the provided data. + If meta.partial is true, it updates even an active subscription. + */ +module.exports.insert = (listId, meta, subscriptionData, callback) => { meta = tools.convertKeys(meta); - subscription = tools.convertKeys(subscription); + subscriptionData = tools.convertKeys(subscriptionData); - meta.email = meta.email || subscription.email; + meta.email = meta.email || subscriptionData.email; meta.cid = meta.cid || shortid.generate(); fields.list(listId, (err, fieldList) => { @@ -292,8 +116,8 @@ module.exports.insert = (listId, meta, subscription, callback) => { let values = []; let allowedKeys = ['first_name', 'last_name', 'tz', 'is_test']; - Object.keys(subscription).forEach(key => { - let value = subscription[key]; + Object.keys(subscriptionData).forEach(key => { + let value = subscriptionData[key]; key = tools.toDbKey(key); if (key === 'tz') { value = (value || '').toString().toLowerCase().trim(); @@ -307,7 +131,7 @@ module.exports.insert = (listId, meta, subscription, callback) => { } }); - fields.getValues(fields.getRow(fieldList, subscription, true, true, !!meta.partial), true).forEach(field => { + fields.getValues(fields.getRow(fieldList, subscriptionData, true, true, !!meta.partial), true).forEach(field => { keys.push(field.key); values.push(field.value); }); @@ -326,10 +150,7 @@ module.exports.insert = (listId, meta, subscription, callback) => { let query = 'SELECT `id`, `status`, `cid` FROM `subscription__' + listId + '` WHERE `email`=? OR `cid`=? LIMIT 1'; connection.query(query, [meta.email, meta.cid], (err, rows) => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } let query; @@ -338,25 +159,26 @@ module.exports.insert = (listId, meta, subscription, callback) => { let entryId = existing ? existing.id : false; meta.cid = existing ? rows[0].cid : meta.cid; - meta.status = meta.status || (existing ? existing.status : 1); + meta.status = meta.status || (existing ? existing.status : Status.SUBSCRIBED); let statusChange = !existing || existing.status !== meta.status; let statusDirection; + if (existing && existing.status === Status.SUBSCRIBED && !meta.partial) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Email address already registered')))); + } + if (statusChange) { keys.push('status', 'status_change'); values.push(meta.status, new Date()); - statusDirection = !existing ? (meta.status === 1 ? '+' : false) : (existing.status === 1 ? '-' : '+'); + statusDirection = !existing ? (meta.status === Status.SUBSCRIBED ? '+' : false) : (existing.status === Status.SUBSCRIBED ? '-' : '+'); } if (!keys.length) { // nothing to update return connection.commit(err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.release(); return callback(null, { @@ -380,10 +202,7 @@ module.exports.insert = (listId, meta, subscription, callback) => { connection.query(query, queryArgs, (err, result) => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } entryId = result.insertId || entryId; @@ -391,17 +210,11 @@ module.exports.insert = (listId, meta, subscription, callback) => { if (statusChange && statusDirection) { connection.query('UPDATE lists SET `subscribers`=`subscribers`' + statusDirection + '1 WHERE id=?', [listId], err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.commit(err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.release(); return callback(null, { @@ -414,10 +227,7 @@ module.exports.insert = (listId, meta, subscription, callback) => { } else { connection.commit(err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.release(); return callback(null, { @@ -575,7 +385,7 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => { } if (!cid) { - return callback(new Error(_('Missing subscription ID'))); + return callback(new Error(_('Missing Subscription ID'))); } fields.list(listId, (err, fieldList) => { @@ -627,46 +437,7 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => { }); }; -module.exports.unsubscribe = (listId, email, campaignId, callback) => { - listId = Number(listId) || 0; - email = (email || '').toString().trim(); - - campaignId = (campaignId || '').toString().trim() || false; - - if (listId < 1) { - return callback(new Error(_('Missing List ID'))); - } - - if (!email) { - return callback(new Error(_('Missing email address'))); - } - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - connection.query('SELECT * FROM `subscription__' + listId + '` WHERE `email`=?', [email], (err, rows) => { - connection.release(); - if (err) { - return callback(err); - } - if (!rows || !rows.length || rows[0].status !== 1) { - return callback(null, false); - } - - let subscription = tools.convertKeys(rows[0]); - module.exports.changeStatus(subscription.id, listId, campaignId, 2, err => { - if (err) { - return callback(err); - } - return callback(null, subscription); - }); - }); - }); -}; - -module.exports.changeStatus = (id, listId, campaignId, status, callback) => { +module.exports.changeStatus = (listId, id, campaignId, status, callback) => { db.getConnection((err, connection) => { if (err) { return callback(err); @@ -679,17 +450,11 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => { connection.query('SELECT `status` FROM `subscription__' + listId + '` WHERE id=? LIMIT 1', [id], (err, rows) => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } if (!rows || !rows.length) { - return connection.rollback(() => { - connection.release(); - return callback(null, false); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(null, false)); } let oldStatus = rows[0].status; @@ -697,31 +462,22 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => { let statusDirection; if (!statusChange) { - return connection.rollback(() => { - connection.release(); - return callback(null, true); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(null, true)); } - if (statusChange && oldStatus === 1 || status === 1) { - statusDirection = status === 1 ? '+' : '-'; + if (statusChange && oldStatus === Status.SUBSCRIBED || status === Status.SUBSCRIBED) { + statusDirection = status === Status.SUBSCRIBED ? '+' : '-'; } connection.query('UPDATE `subscription__' + listId + '` SET `status`=?, `status_change`=NOW() WHERE id=? LIMIT 1', [status, id], err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } if (!statusDirection) { return connection.commit(err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.release(); return callback(null, true); @@ -730,20 +486,14 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => { connection.query('UPDATE `lists` SET `subscribers`=`subscribers`' + statusDirection + '1 WHERE id=? LIMIT 1', [listId], err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } // status change is not related to a campaign or it marks message as bounced etc. - if (!campaignId || status > 2) { + if (!campaignId || status !== Status.SUBSCRIBED) { return connection.commit(err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.release(); return callback(null, true); @@ -752,10 +502,7 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => { connection.query('SELECT `id` FROM `campaigns` WHERE `cid`=? LIMIT 1', [campaignId], (err, rows) => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } let campaign = rows && rows[0] || false; @@ -764,10 +511,7 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => { // should not happend return connection.commit(err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.release(); return callback(null, true); @@ -775,12 +519,9 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => { } // we should see only unsubscribe events here but you never know - connection.query('UPDATE `campaigns` SET `unsubscribed`=`unsubscribed`' + (status === 2 ? '+' : '-') + '1 WHERE `cid`=? LIMIT 1', [campaignId], err => { + connection.query('UPDATE `campaigns` SET `unsubscribed`=`unsubscribed`' + (status === Status.UNSUBSCRIBED ? '+' : '-') + '1 WHERE `cid`=? LIMIT 1', [campaignId], err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } let query = 'UPDATE `campaign__' + campaign.id + '` SET `status`=? WHERE `list`=? AND `subscription`=? LIMIT 1'; @@ -789,18 +530,12 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => { // Updated tracker status connection.query(query, values, err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } return connection.commit(err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.release(); return callback(null, true); @@ -852,19 +587,13 @@ module.exports.delete = (listId, cid, callback) => { connection.query('DELETE FROM `subscription__' + listId + '` WHERE cid=? LIMIT 1', [cid], err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } - if (subscription.status !== 1) { + if (subscription.status !== Status.SUBSCRIBED) { return connection.commit(err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.release(); return callback(null, subscription.email); @@ -873,17 +602,11 @@ module.exports.delete = (listId, cid, callback) => { connection.query('UPDATE lists SET subscribers=subscribers-1 WHERE id=? LIMIT 1', [listId], err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.commit(err => { if (err) { - return connection.rollback(() => { - connection.release(); - return callback(err); - }); + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } connection.release(); return callback(null, subscription.email); @@ -963,11 +686,10 @@ module.exports.updateImport = (listId, importId, data, callback) => { connection.release(); return callback(null, affected); }); - return; + } else { + connection.release(); + return callback(null, affected); } - - connection.release(); - return callback(null, affected); }); }); }; @@ -1075,13 +797,13 @@ module.exports.listImports = (listId, callback) => { }); }; - -module.exports.updateAddress = (list, cid, updates, optInIp, callback) => { - updates = tools.convertKeys(updates); +/* +Performs checks before update of an address. This includes finding the existing subscriber, validating the new email +and checking whether the new email does not conflict with other subscribers. + */ +module.exports.updateAddressCheck = (list, cid, emailNew, ip, callback) => { cid = (cid || '').toString().trim(); - let emailNew = (updates.emailNew || '').toString().trim(); - if (!list || !list.id) { return callback(new Error(_('Missing List ID'))); } @@ -1100,7 +822,7 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => { return callback(err); } - let query = 'SELECT `id`, `email` FROM `subscription__' + list.id + '` WHERE `cid`=? LIMIT 1'; + let query = 'SELECT * FROM `subscription__' + list.id + '` WHERE `cid`=? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1'; let args = [cid]; connection.query(query, args, (err, rows) => { if (err) { @@ -1119,7 +841,7 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => { let old = rows[0]; - let query = 'SELECT `id` FROM `subscription__' + list.id + '` WHERE `email`=? AND `cid`<>? LIMIT 1'; + let query = 'SELECT `id` FROM `subscription__' + list.id + '` WHERE `email`=? AND `cid`<>? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1'; let args = [emailNew, cid]; connection.query(query, args, (err, rows) => { connection.release(); @@ -1127,18 +849,77 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => { return callback(err); } - if (rows && rows[0] && rows[0].id) { - return callback(new Error(_('This address is already registered by someone else'))); + if (rows && rows.length > 0) { + return callback(null, old, false); + } else { + return callback(null, old, true); } - - module.exports.addConfirmation(list, emailNew, optInIp, { - action: 'update', - cid, - subscriber: old.id, - emailOld: old.email - }, callback); }); }); }); }); }; + + +/* + Updates address in subscription__xxx + */ +module.exports.updateAddress = (listId, subscriptionId, emailNew, callback) => { + // update email address instead of adding new + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + connection.beginTransaction(err => { + if (err) { + connection.release(); + return callback(err); + } + + let query = 'SELECT `id` FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1'; + let args = [emailNew, subscriptionId]; + connection.query(query, args, (err, rows) => { + if (err) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); + } + + if (rows && rows.length > 0) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Email address already registered')))); + } + + let query = 'DELETE FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>?'; + let args = [emailNew, subscriptionId]; + connection.query(query, args, err => { + if (err) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); + } + + let query = 'UPDATE `subscription__' + listId + '` SET `email`=? WHERE `id`=? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1'; + let args = [emailNew, subscriptionId]; + connection.query(query, args, (err, result) => { + if (err) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); + } + + if (!result || !result.affectedRows) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Subscription not found in this list')))); + } + + return connection.commit(err => { + if (err) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); + } + connection.release(); + + return callback(); + }); + }); + }); + }); + }); + }); + +}; + +module.exports.getUnsubscriptionMode = (list, subscriptionId) => list.unsubscriptionMode; // eslint-disable-line no-unused-vars +// TODO: Once the unsubscription mode is customizable per segment, then this will be a good place to process it. diff --git a/lib/subscription-mail-helpers.js b/lib/subscription-mail-helpers.js new file mode 100644 index 00000000..cfbac97e --- /dev/null +++ b/lib/subscription-mail-helpers.js @@ -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(); + }); + }); +} diff --git a/lib/tools.js b/lib/tools.js index 2c508917..2e7a32e2 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -43,7 +43,13 @@ function toDbKey(key) { } function fromDbKey(key) { - return key.replace(/[_\-]([a-z])/g, (m, c) => c.toUpperCase()); + let prefix = ''; + if (key.startsWith('_')) { + key = key.substring(1); + prefix = '_'; + + } + return prefix + key.replace(/[_\-]([a-z])/g, (m, c) => c.toUpperCase()); } function convertKeys(obj, options) { @@ -54,7 +60,7 @@ function convertKeys(obj, options) { if (options.skip && options.skip.indexOf(lKey) >= 0) { return; } - if (options.keep && options.skip.indexOf(lKey) < 0) { + if (options.keep && options.keep.indexOf(lKey) < 0) { return; } response[lKey] = obj[key]; @@ -142,7 +148,6 @@ function updateMenu(res) { } function validateEmail(address, checkBlocked, callback) { - let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, ''); if (checkBlocked && blockedUsers.indexOf(user) >= 0) { return callback(new Error(util.format(_('Blocked email address "%s"'), address))); @@ -175,7 +180,7 @@ function validateEmail(address, checkBlocked, callback) { function getMessageLinks(serviceUrl, campaign, list, subscription) { return { - LINK_UNSUBSCRIBE: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?auto=yes&c=' + campaign.cid), + LINK_UNSUBSCRIBE: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?c=' + campaign.cid), LINK_PREFERENCES: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid), LINK_BROWSER: urllib.resolve(serviceUrl, '/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid), CAMPAIGN_ID: campaign.cid, diff --git a/meta.json b/meta.json index 22fe97fb..aa851e29 100644 --- a/meta.json +++ b/meta.json @@ -1,3 +1,3 @@ { - "schemaVersion": 27 + "schemaVersion": 28 } diff --git a/package.json b/package.json index 4bd73811..f26496e3 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "sqldumptest": "NODE_ENV=test DUMP_NAME_SUFFIX=-test npm run sqldump", "sqlresettest": "NODE_ENV=test npm run sqldrop && NODE_ENV=test npm run sqlinit", "starttest": "NODE_ENV=test node index.js", - "_e2e": "NODE_ENV=test mocha test/e2e/index.js", + "_e2e": "NODE_ENV=test node test/e2e/index.js", "e2e": "npm run sqlresettest && npm run _e2e" }, "repository": { @@ -41,7 +41,8 @@ "jsxgettext-andris": "^0.9.0-patch.1", "mocha": "^3.3.0", "phantomjs": "^2.1.7", - "selenium-webdriver": "^3.4.0" + "selenium-webdriver": "^3.4.0", + "url-pattern": "^1.0.3" }, "optionalDependencies": { "posix": "^4.1.1" @@ -69,6 +70,7 @@ "faker": "^4.1.0", "feedparser": "^2.1.0", "file-type": "^4.1.0", + "fs-extra": "^3.0.1", "geoip-ultralight": "^0.1.5", "gettext-parser": "^1.2.2", "gm": "^1.23.0", diff --git a/routes/api.js b/routes/api.js index ce71a7c1..a44d6fec 100644 --- a/routes/api.js +++ b/routes/api.js @@ -5,10 +5,12 @@ let lists = require('../lib/models/lists'); let fields = require('../lib/models/fields'); let blacklist = require('../lib/models/blacklist'); let subscriptions = require('../lib/models/subscriptions'); +let confirmations = require('../lib/models/confirmations'); let tools = require('../lib/tools'); let express = require('express'); let log = require('npmlog'); let router = new express.Router(); +let mailHelpers = require('../lib/subscription-mail-helpers'); router.all('/*', (req, res, next) => { if (!req.query.access_token) { @@ -123,7 +125,7 @@ router.post('/subscribe/:listId', (req, res) => { } if (/^(yes|true|1)$/i.test(input.REQUIRE_CONFIRMATION)) { - subscriptions.addConfirmation(list, input.EMAIL, req.ip, subscription, (err, cid) => { + confirmations.addConfirmation(list.id, 'subscribe', req.ip, subscription, (err, confirmCid) => { if (err) { log.error('API', err); res.status(500); @@ -132,11 +134,23 @@ router.post('/subscribe/:listId', (req, res) => { data: [] }); } - res.status(200); - res.json({ - data: { - id: cid + + mailHelpers.sendConfirmSubscription(list, input.EMAIL, confirmCid, subscription, (err) => { + if (err) { + log.error('API', err); + res.status(500); + return res.json({ + error: err.message || err, + data: [] + }); } + + res.status(200); + res.json({ + data: { + id: confirmCid + } + }); }); }); } else { @@ -189,7 +203,8 @@ router.post('/unsubscribe/:listId', (req, res) => { data: [] }); } - subscriptions.unsubscribe(list.id, input.EMAIL, false, (err, subscription) => { + + subscriptions.getByEmail(list.id, input.EMAIL, (err, subscription) => { if (err) { res.status(500); return res.json({ @@ -197,12 +212,30 @@ router.post('/unsubscribe/:listId', (req, res) => { data: [] }); } - res.status(200); - res.json({ - data: { - id: subscription.id, - unsubscribed: true + + if (!subscription) { + res.status(404); + return res.json({ + error: 'Subscription with given email not found', + data: [] + }); + } + + subscriptions.changeStatus(list.id, subscription.id, false, subscriptions.Status.UNSUBSCRIBED, (err, found) => { + if (err) { + res.status(500); + return res.json({ + error: err.message || err, + data: [] + }); } + res.status(200); + res.json({ + data: { + id: subscription.id, + unsubscribed: true + } + }); }); }); }); diff --git a/routes/forms.js b/routes/forms.js index 17c5e896..ef10bd9a 100644 --- a/routes/forms.js +++ b/routes/forms.js @@ -161,22 +161,32 @@ router.get('/:list/edit/:form', passport.csrfProtection, (req, res) => { type: 'mjml', help: helpMjmlGeneral }, { - name: 'web_confirm_notice', - label: _('Web - Confirm Notice'), + name: 'web_confirm_subscription_notice', + label: _('Web - Confirm Subscription Notice'), type: 'mjml', help: helpMjmlGeneral }, { - name: 'mail_confirm_html', + name: 'mail_confirm_subscription_html', label: _('Mail - Confirm Subscription (MJML)'), type: 'mjml', help: helpMjmlGeneral }, { - name: 'mail_confirm_text', + name: 'mail_confirm_subscription_text', label: _('Mail - Confirm Subscription (Text)'), type: 'text', help: helpEmailText }, { - name: 'web_subscribed', + name: 'mail_already_subscribed_html', + label: _('Mail - Already Subscribed (MJML)'), + type: 'mjml', + help: helpMjmlGeneral + }, { + name: 'mail_already_subscribed_text', + label: _('Mail - Already Subscribed (Text)'), + type: 'text', + help: helpEmailText + }, { + name: 'web_subscribed_notice', label: _('Web - Subscribed Notice'), type: 'mjml', help: helpMjmlGeneral @@ -217,20 +227,50 @@ router.get('/:list/edit/:form', passport.csrfProtection, (req, res) => { type: 'mjml', help: helpMjmlGeneral }, { - name: 'web_unsubscribe_notice', - label: _('Web - Unsubscribe Notice'), + name: 'web_confirm_unsubscription_notice', + label: _('Web - Confirm Unsubscription Notice'), type: 'mjml', help: helpMjmlGeneral }, { - name: 'mail_unsubscribe_confirmed_html', - label: _('Mail - Unsubscribe Confirmed (MJML)'), + name: 'mail_confirm_unsubscription_html', + label: _('Mail - Confirm Unsubscription (MJML)'), type: 'mjml', help: helpMjmlGeneral }, { - name: 'mail_unsubscribe_confirmed_text', - label: _('Mail - Unsubscribe Confirmed (Text)'), + name: 'mail_confirm_unsubscription_text', + label: _('Mail - Confirm Unsubscription (Text)'), type: 'text', help: helpEmailText + }, { + name: 'mail_confirm_address_change_html', + label: _('Mail - Confirm Address Change (MJML)'), + type: 'mjml', + help: helpMjmlGeneral + }, { + name: 'mail_confirm_address_change_text', + label: _('Mail - Confirm Address Change (Text)'), + type: 'text', + help: helpEmailText + }, { + name: 'web_unsubscribed_notice', + label: _('Web - Unsubscribed Notice'), + type: 'mjml', + help: helpMjmlGeneral + }, { + name: 'mail_unsubscription_confirmed_html', + label: _('Mail - Unsubscription Confirmed (MJML)'), + type: 'mjml', + help: helpMjmlGeneral + }, { + name: 'mail_unsubscription_confirmed_text', + label: _('Mail - Unsubscription Confirmed (Text)'), + type: 'text', + help: helpEmailText + }, { + name: 'web_manual_unsubscribe_notice', + label: _('Web - Manual Unsubscribe Notice'), + type: 'mjml', + help: helpMjmlGeneral }] } ]; diff --git a/routes/lists.js b/routes/lists.js index 64c1352f..4c28bf32 100644 --- a/routes/lists.js +++ b/routes/lists.js @@ -71,6 +71,8 @@ router.get('/create', passport.csrfProtection, (req, res) => { data.publicSubscribe = true; } + data.unsubscriptionModeOptions = getUnsubscriptionModeOptions(data.unsubscriptionMode || lists.UnsubscriptionMode.ONE_STEP); + res.render('lists/create', data); }); @@ -103,6 +105,8 @@ router.get('/edit/:id', passport.csrfProtection, (req, res) => { return row; }); + list.unsubscriptionModeOptions = getUnsubscriptionModeOptions(list.unsubscriptionMode); + list.csrfToken = req.csrfToken(); res.render('lists/edit', list); }); @@ -447,7 +451,7 @@ router.post('/subscription/unsubscribe', passport.parseForm, passport.csrfProtec return res.redirect('/lists/view/' + list.id); } - subscriptions.unsubscribe(list.id, subscription.email, false, err => { + subscriptions.changeStatus(list.id, subscription.id, false, subscriptions.Status.UNSUBSCRIBED, (err, found) => { if (err) { req.flash('danger', err && err.message || err || _('Could not unsubscribe user')); return res.redirect('/lists/subscription/' + list.id + '/edit/' + subscription.cid); @@ -771,4 +775,40 @@ router.post('/quicklist/ajax', (req, res) => { }); }); +function getUnsubscriptionModeOptions(unsubscriptionMode) { + const options = []; + + options[lists.UnsubscriptionMode.ONE_STEP] = { + value: lists.UnsubscriptionMode.ONE_STEP, + selected: unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP, + label: _('One-step (i.e. no email with confirmation link)') + }; + + options[lists.UnsubscriptionMode.ONE_STEP_WITH_FORM] = { + value: lists.UnsubscriptionMode.ONE_STEP_WITH_FORM, + selected: unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM, + label: _('One-step with unsubscription form (i.e. no email with confirmation link)') + }; + + options[lists.UnsubscriptionMode.TWO_STEP] = { + value: lists.UnsubscriptionMode.TWO_STEP, + selected: unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP, + label: _('Two-step (i.e. an email with confirmation link will be sent)') + }; + + options[lists.UnsubscriptionMode.TWO_STEP_WITH_FORM] = { + value: lists.UnsubscriptionMode.TWO_STEP_WITH_FORM, + selected: unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM, + label: _('Two-step with unsubscription form (i.e. an email with confirmation link will be sent)') + }; + + options[lists.UnsubscriptionMode.MANUAL] = { + value: lists.UnsubscriptionMode.MANUAL, + selected: unsubscriptionMode === lists.UnsubscriptionMode.MANUAL, + label: _('Manual (i.e. unsubscription has to be performed by the list administrator)') + }; + + return options; +} + module.exports = router; diff --git a/routes/subscription.js b/routes/subscription.js index 0e91eb98..6310bbf3 100644 --- a/routes/subscription.js +++ b/routes/subscription.js @@ -4,10 +4,8 @@ let log = require('npmlog'); let config = require('config'); let tools = require('../lib/tools'); let helpers = require('../lib/helpers'); -let mailer = require('../lib/mailer'); let passport = require('../lib/passport'); let express = require('express'); -let urllib = require('url'); let router = new express.Router(); let lists = require('../lib/models/lists'); let fields = require('../lib/models/fields'); @@ -18,6 +16,9 @@ let _ = require('../lib/translate')._; let util = require('util'); let cors = require('cors'); let cache = require('memory-cache'); +let geoip = require('geoip-ultralight'); +let confirmations = require('../lib/models/confirmations'); +let mailHelpers = require('../lib/subscription-mail-helpers'); let originWhitelist = config.cors && config.cors.origins || []; @@ -44,10 +45,10 @@ let corsOrCsrfProtection = (req, res, next) => { } }; -router.get('/subscribe/:cid', (req, res, next) => { - subscriptions.subscribe(req.params.cid, req.ip, (err, subscription) => { - if (!err && !subscription) { - err = new Error(_('Selected subscription not found')); +function checkAndExecuteConfirmation(req, action, errorMsg, next, exec) { + confirmations.takeConfirmation(req.params.cid, (err, confirmation) => { + if (!err && (!confirmation || confirmation.action !== action)) { + err = new Error(_(errorMsg)); err.status = 404; } @@ -55,7 +56,7 @@ router.get('/subscribe/:cid', (req, res, next) => { return next(err); } - lists.get(subscription.list, (err, list) => { + lists.get(confirmation.listId, (err, list) => { if (!err && !list) { err = new Error(_('Selected list not found')); err.status = 404; @@ -65,109 +66,102 @@ router.get('/subscribe/:cid', (req, res, next) => { return next(err); } - settings.list(['defaultHomepage', 'serviceUrl', 'pgpPrivateKey', 'defaultAddress', 'defaultPostaddress', 'defaultFrom', 'disableConfirmations'], (err, configItems) => { + exec(confirmation, list); + }); + }); +} + +router.get('/confirm/subscribe/:cid', (req, res, next) => { + checkAndExecuteConfirmation(req, 'subscribe', 'Request invalid or already completed. If your subscription request is still pending, please subscribe again.', next, (confirmation, list) => { + const data = confirmation.data; + let optInCountry = geoip.lookupCountry(confirmation.ip) || null; + + const meta = { + email: data.email, + optInIp: confirmation.ip, + optInCountry, + status: subscriptions.Status.SUBSCRIBED + }; + + subscriptions.insert(list.id, meta, data.subscriptionData, (err, result) => { + if (err) { + return next(err); + } + + if (!result.entryId) { + return next(new Error(_('Could not save subscription'))); + } + + subscriptions.getById(list.id, result.entryId, (err, subscription) => { if (err) { return next(err); } - let data = { - title: list.name, - homepage: configItems.defaultHomepage || configItems.serviceUrl, - preferences: '/subscription/' + list.cid + '/manage/' + subscription.cid, - hasPubkey: !!configItems.pgpPrivateKey, - defaultAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - template: { - template: 'subscription/web-subscribed.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - } - }; - - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribed', data, (err, data) => { + mailHelpers.sendSubscriptionConfirmed(list, data.email, subscription, err => { if (err) { return next(err); } - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { - if (err) { - return next(err); - } - - helpers.captureFlashMessages(req, res, (err, flash) => { - if (err) { - return next(err); - } - - data.isWeb = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); - }); - }); + res.redirect('/subscription/' + list.cid + '/subscribed-notice'); }); + }); + }); + }); +}); - if (configItems.disableConfirmations) { - return; +router.get('/confirm/change-address/:cid', (req, res, next) => { + checkAndExecuteConfirmation(req, 'change-address', 'Request invalid or already completed. If your address change request is still pending, please change the address again.', next, (confirmation, list) => { + const data = confirmation.data; + + if (!data.subscriptionId) { // Something went terribly wrong and we don't have data that we have originally provided + return next(new Error(_('Subscriber info corrupted or missing'))); + } + + subscriptions.updateAddress(list.id, data.subscriptionId, data.emailNew, err => { + if (err) { + return next(err); + } + + subscriptions.getById(list.id, data.subscriptionId, (err, subscription) => { + if (err) { + return next(err); } - fields.list(list.id, (err, fieldList) => { + mailHelpers.sendSubscriptionConfirmed(list, data.emailNew, subscription, err => { if (err) { - return log.error('Fields', err); + return next(err); } - let encryptionKeys = []; - fields.getRow(fieldList, subscription).forEach(field => { - if (field.type === 'gpg' && field.value) { - encryptionKeys.push(field.value.trim()); - } - }); + req.flash('info', _('Email address changed')); + res.redirect('/subscription/' + list.cid + '/manage/' + subscription.cid); + }); + }); + }); + }); +}); - let sendMail = (html, text) => { - mailer.sendMail({ - from: { - name: configItems.defaultFrom, - address: configItems.defaultAddress - }, - to: { - name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '), - address: subscription.email - }, - subject: util.format(_('%s: Subscription Confirmed'), list.name), - encryptionKeys - }, { - html, - text, - data: { - title: list.name, - homepage: configItems.defaultHomepage || configItems.serviceUrl, - contactAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - preferencesUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid), - unsubscribeUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid) - } - }, err => { - if (err) { - log.error('Subscription', err); - } - }); - }; +router.get('/confirm/unsubscribe/:cid', (req, res, next) => { + checkAndExecuteConfirmation(req, 'unsubscribe', 'Request invalid or already completed. If your unsubscription request is still pending, please unsubscribe again.', next, (confirmation, list) => { + const data = confirmation.data; - let text = { - template: 'subscription/mail-subscription-confirmed-text.hbs' - }; + subscriptions.changeStatus(list.id, confirmation.data.subscriptionId, confirmation.data.campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => { + if (err) { + return next(err); + } - let html = { - template: 'subscription/mail-subscription-confirmed-html.mjml.hbs', - layout: 'subscription/layout.mjml.hbs', - type: 'mjml' - }; + // TODO: Shall we do anything with "found"? - helpers.injectCustomFormTemplates(req.query.fid || list.defaultForm, { text, html }, (err, tmpl) => { - if (err) { - return sendMail(html, text); - } + subscriptions.getById(list.id, confirmation.data.subscriptionId, (err, subscription) => { + if (err) { + return next(err); + } - sendMail(tmpl.html, tmpl.text); - }); + mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => { + if (err) { + return next(err); + } + + res.redirect('/subscription/' + list.cid + '/unsubscribed-notice'); }); }); }); @@ -190,6 +184,8 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => { return next(err); } + // TODO: process subscriber cid param for resubscription requests + let data = tools.convertKeys(req.query, { skip: ['layout'] }); @@ -198,51 +194,74 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => { data.cid = list.cid; data.csrfToken = req.csrfToken(); - fields.list(list.id, (err, fieldList) => { - if (err && !fieldList) { - fieldList = []; - } - data.customFields = fields.getRow(fieldList, data); - data.useEditor = true; - - settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { - if (err) { - return next(err); + function nextStep() { + fields.list(list.id, (err, fieldList) => { + if (err && !fieldList) { + fieldList = []; } - data.hasPubkey = !!configItems.pgpPrivateKey; - data.defaultAddress = configItems.defaultAddress; - data.defaultPostaddress = configItems.defaultPostaddress; - data.template = { - template: 'subscription/web-subscribe.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - }; + data.customFields = fields.getRow(fieldList, data); + data.useEditor = true; - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => { + settings.list(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { if (err) { return next(err); } + data.hasPubkey = !!configItems.pgpPrivateKey; + data.defaultAddress = configItems.defaultAddress; + data.defaultPostaddress = configItems.defaultPostaddress; - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { + data.template = { + template: 'subscription/web-subscribe.mjml.hbs', + layout: 'subscription/layout.mjml.hbs' + }; + + helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => { if (err) { return next(err); } - helpers.captureFlashMessages(req, res, (err, flash) => { + helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { if (err) { return next(err); } - data.isWeb = true; - data.needsJsWarning = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); + helpers.captureFlashMessages(req, res, (err, flash) => { + if (err) { + return next(err); + } + + data.isWeb = true; + data.needsJsWarning = true; + data.flashMessages = flash; + res.send(htmlRenderer(data)); + }); }); }); }); }); - }); + } + + + const ucid = req.query.cid; + if (ucid) { + subscriptions.get(list.id, ucid, (err, subscription) => { + if (err) { + return next(err); + } + + for (let key in subscription) { + if (!(key in data)) { + data[key] = subscription[key]; + } + } + + nextStep(); + }); + } else { + nextStep(); + } }); }); @@ -318,164 +337,6 @@ router.get('/:cid/widget', cors(corsOptions), (req, res, next) => { }); }); -router.get('/:cid/confirm-notice', (req, res, next) => { - lists.getByCid(req.params.cid, (err, list) => { - if (!err && !list) { - err = new Error(_('Selected list not found')); - err.status = 404; - } - - if (err) { - return next(err); - } - - settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { - if (err) { - return next(err); - } - - let data = { - title: list.name, - homepage: configItems.defaultHomepage || configItems.serviceUrl, - defaultAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - template: { - template: 'subscription/web-confirm-notice.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - } - }; - - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-confirm-notice', data, (err, data) => { - if (err) { - return next(err); - } - - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { - if (err) { - return next(err); - } - - helpers.captureFlashMessages(req, res, (err, flash) => { - if (err) { - return next(err); - } - - data.isWeb = true; - data.isConfirmNotice = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); - }); - }); - }); - }); - }); -}); - -router.get('/:cid/updated-notice', (req, res, next) => { - lists.getByCid(req.params.cid, (err, list) => { - if (!err && !list) { - err = new Error(_('Selected list not found')); - err.status = 404; - } - - if (err) { - return next(err); - } - - settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { - if (err) { - return next(err); - } - - let data = { - title: list.name, - homepage: configItems.defaultHomepage || configItems.serviceUrl, - defaultAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - template: { - template: 'subscription/web-updated-notice.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - } - }; - - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-updated-notice', data, (err, data) => { - if (err) { - return next(err); - } - - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { - if (err) { - return next(err); - } - - helpers.captureFlashMessages(req, res, (err, flash) => { - if (err) { - return next(err); - } - - data.isWeb = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); - }); - }); - }); - }); - }); -}); - -router.get('/:cid/unsubscribe-notice', (req, res, next) => { - lists.getByCid(req.params.cid, (err, list) => { - if (!err && !list) { - err = new Error(_('Selected list not found')); - err.status = 404; - } - - if (err) { - return next(err); - } - - settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { - if (err) { - return next(err); - } - - let data = { - title: list.name, - layout: 'subscription/layout', - homepage: configItems.defaultHomepage || configItems.serviceUrl, - defaultAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - template: { - template: 'subscription/web-unsubscribe-notice.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - } - }; - - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-unsubscribe-notice', data, (err, data) => { - if (err) { - return next(err); - } - - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { - if (err) { - return next(err); - } - - helpers.captureFlashMessages(req, res, (err, flash) => { - if (err) { - return next(err); - } - - data.isWeb = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); - }); - }); - }); - }); - }); -}); - router.options('/:cid/subscribe', cors(corsOptions)); router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, res, next) => { @@ -496,62 +357,97 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); } - // Check if the subscriber seems legit. This is a really simple check, the only requirement is that - // the subsciber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this - // simple check should be replaced with an actual captcha - let subTime = Number(req.body.sub) || 0; - // allow clock skew 24h in the past and 24h to the future - let subTimeTest = !!(subTime > Date.now() - 24 * 3600 * 1000 && subTime < Date.now() + 24 * 3600 * 1000); - let addressTest = !req.body.address; - let testsPass = subTimeTest && addressTest; - - lists.getByCid(req.params.cid, (err, list) => { - if (!err) { - if (!list) { - err = new Error(_('Selected list not found')); - err.status = 404; - } else if (!list.publicSubscribe) { - err = new Error(_('The list does not allow public subscriptions.')); - err.status = 403; - } - } - + tools.validateEmail(email, false, err => { if (err) { - return req.xhr ? sendJsonError(err) : next(err); + if (req.xhr) { + return sendJsonError(err.message, 400); + } + req.flash('danger', err.message); + return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); } - let data = {}; - Object.keys(req.body).forEach(key => { - if (key !== 'email' && key.charAt(0) !== '_') { - data[key] = (req.body[key] || '').toString().trim(); - } - }); + // Check if the subscriber seems legit. This is a really simple check, the only requirement is that + // the subscriber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this + // simple check should be replaced with an actual captcha + let subTime = Number(req.body.sub) || 0; + // allow clock skew 24h in the past and 24h to the future + let subTimeTest = !!(subTime > Date.now() - 24 * 3600 * 1000 && subTime < Date.now() + 24 * 3600 * 1000); + let addressTest = !req.body.address; + let testsPass = subTimeTest && addressTest; - data = tools.convertKeys(data); - - data._address = req.body.address; - data._sub = req.body.sub; - data._skip = !testsPass; - - subscriptions.addConfirmation(list, email, req.ip, data, (err, confirmCid) => { - if (!err && !confirmCid) { - err = new Error(_('Could not store confirmation data')); + lists.getByCid(req.params.cid, (err, list) => { + if (!err) { + if (!list) { + err = new Error(_('Selected list not found')); + err.status = 404; + } else if (!list.publicSubscribe) { + err = new Error(_('The list does not allow public subscriptions.')); + err.status = 403; + } } if (err) { - if (req.xhr) { - return sendJsonError(err); - } - req.flash('danger', err.message || err); - return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); + return req.xhr ? sendJsonError(err) : next(err); } - if (req.xhr) { - return res.status(200).json({ - msg: _('Please Confirm Subscription') - }); - } - res.redirect('/subscription/' + req.params.cid + '/confirm-notice'); + let subscriptionData = {}; + Object.keys(req.body).forEach(key => { + if (key !== 'email' && key.charAt(0) !== '_') { + subscriptionData[key] = (req.body[key] || '').toString().trim(); + } + }); + subscriptionData = tools.convertKeys(subscriptionData); + + subscriptions.getByEmail(list.id, email, (err, subscription) => { + if (err) { + return req.xhr ? sendJsonError(err) : next(err); + } + + if (subscription && subscription.status === subscriptions.Status.SUBSCRIBED) { + mailHelpers.sendAlreadySubscribed(list, email, subscription, (err) => { + if (err) { + return req.xhr ? sendJsonError(err) : next(err); + } + res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice'); + }); + } else { + const data = { + email, + subscriptionData + }; + + confirmations.addConfirmation(list.id, 'subscribe', req.ip, data, (err, confirmCid) => { + if (err) { + if (req.xhr) { + return sendJsonError(err); + } + req.flash('danger', err.message || err); + return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); + } + + function sendWebResponse() { + if (req.xhr) { + return res.status(200).json({ + msg: _('Please Confirm Subscription') + }); + } + res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice'); + } + + if (!testsPass) { + log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data)); + sendWebResponse(); + } else { + mailHelpers.sendConfirmSubscription(list, email, confirmCid, data, (err) => { + if (err) { + return req.xhr ? sendJsonError(err) : sendWebResponse(err); + } + sendWebResponse(); + }) + } + }); + } + }); }); }); }); @@ -572,8 +468,8 @@ router.get('/:lcid/manage/:ucid', passport.csrfProtection, (req, res, next) => { return next(err); } subscriptions.get(list.id, req.params.ucid, (err, subscription) => { - if (!err && !subscription) { - err = new Error(_('Subscription not found from this list')); + if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) { + err = new Error(_('Subscription not found in this list')); err.status = 404; } @@ -643,13 +539,22 @@ router.post('/:lcid/manage', passport.parseForm, passport.csrfProtection, (req, return next(err); } - subscriptions.update(list.id, req.body.cid, req.body, false, err => { - if (err) { - req.flash('danger', err.message || err); - log.error('Subscription', err); - return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/manage/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body)); + subscriptions.get(list.id, req.body.cid, (err, subscription) => { + if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) { + err = new Error(_('Subscription not found in this list')); + err.status = 404; } - res.redirect('/subscription/' + req.params.lcid + '/updated-notice'); + + if (err) { + return next(err); + } + + subscriptions.update(list.id, subscription.cid, req.body, false, err => { + if (err) { + return next(err); + } + res.redirect('/subscription/' + req.params.lcid + '/updated-notice'); + }); }); }); }); @@ -671,8 +576,8 @@ router.get('/:lcid/manage-address/:ucid', passport.csrfProtection, (req, res, ne } subscriptions.get(list.id, req.params.ucid, (err, subscription) => { - if (!err && !subscription) { - err = new Error(_('Subscription not found from this list')); + if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) { + err = new Error(_('Subscription not found in this list')); err.status = 404; } @@ -725,16 +630,48 @@ router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection return next(err); } - subscriptions.updateAddress(list, req.body.cid, req.body, req.ip, err => { - if (err) { - req.flash('danger', err.message || err); - log.error('Subscription', err); - return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/manage-address/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body)); - } + let bodyData = tools.convertKeys(req.body); // This is here to convert "email-new" to "emailNew" + const emailOld = (bodyData.email || '').toString().trim(); + const emailNew = (bodyData.emailNew || '').toString().trim(); - req.flash('info', _('Email address updated, check your mailbox for verification instructions')); + if (emailOld === emailNew) { + req.flash('info', _('Nothing seems to be changed')); res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid); - }); + + } else { + subscriptions.updateAddressCheck(list, req.body.cid, emailNew, req.ip, (err, subscription, newEmailAvailable) => { + if (err) { + return next(err); + } + + function sendWebResponse(err) { + if (err) { + return next(err); + } + + req.flash('info', _('An email with further instructions has been sent to the provided address')); + res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid); + } + + if (newEmailAvailable) { + const data = { + subscriptionId: subscription.id, + emailNew + }; + + confirmations.addConfirmation(list.id, 'change-address', req.ip, data, (err, confirmCid) => { + if (err) { + return next(err); + } + + mailHelpers.sendConfirmAddressChange(list, emailNew, confirmCid, subscription, sendWebResponse); + }); + + } else { + mailHelpers.sendAlreadySubscribed(list, emailNew, subscription, sendWebResponse); + } + }); + } }); }); @@ -755,8 +692,8 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) } subscriptions.get(list.id, req.params.ucid, (err, subscription) => { - if (!err && !subscription) { - err = new Error(_('Subscription not found from this list')); + if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) { + err = new Error(_('Subscription not found in this list')); err.status = 404; } @@ -764,40 +701,53 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) return next(err); } - subscription.lcid = req.params.lcid; - subscription.title = list.name; - subscription.csrfToken = req.csrfToken(); - subscription.autosubmit = !!req.query.auto; - subscription.campaign = req.query.c; - subscription.defaultAddress = configItems.defaultAddress; - subscription.defaultPostaddress = configItems.defaultPostaddress; - subscription.template = { - template: 'subscription/web-unsubscribe.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - }; + const autoUnsubscribe = req.query.auto === 'yes'; - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-unsubscribe', subscription, (err, data) => { - if (err) { - return next(err); - } + if (autoUnsubscribe) { + handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next); - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { + } else if (req.query.formTest || + list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM || + list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) { + + subscription.lcid = req.params.lcid; + subscription.ucid = req.params.ucid; + subscription.title = list.name; + subscription.csrfToken = req.csrfToken(); + subscription.campaign = req.query.c; + subscription.defaultAddress = configItems.defaultAddress; + subscription.defaultPostaddress = configItems.defaultPostaddress; + + subscription.template = { + template: 'subscription/web-unsubscribe.mjml.hbs', + layout: 'subscription/layout.mjml.hbs' + }; + + helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-unsubscribe', subscription, (err, data) => { if (err) { return next(err); } - helpers.captureFlashMessages(req, res, (err, flash) => { + helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { if (err) { return next(err); } - data.isWeb = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); + helpers.captureFlashMessages(req, res, (err, flash) => { + if (err) { + return next(err); + } + + data.isWeb = true; + data.flashMessages = flash; + res.send(htmlRenderer(data)); + }); }); }); - }); + } else { // UnsubscriptionMode.ONE_STEP || UnsubscriptionMode.TWO_STEP || UnsubscriptionMode.MANUAL + handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next); + } }); }); }); @@ -814,88 +764,93 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, ( return next(err); } - let email = req.body.email; + const campaignId = (req.body.campaign || '').toString().trim() || false; - subscriptions.unsubscribe(list.id, email, req.body.campaign, (err, subscription) => { - if (err) { - req.flash('danger', err.message || err); - log.error('Subscription', err); - return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/unsubscribe/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body)); + subscriptions.get(list.id, req.body.ucid, (err, subscription) => { + if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) { + err = new Error(_('Subscription not found in this list')); + err.status = 404; } - res.redirect('/subscription/' + req.params.lcid + '/unsubscribe-notice'); - fields.list(list.id, (err, fieldList) => { - if (err) { - return log.error('Fields', err); - } + if (err) { + return next(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 log.error('Settings', err); - } - - if (configItems.disableConfirmations) { - return; - } - - let sendMail = (html, text) => { - mailer.sendMail({ - from: { - name: configItems.defaultFrom, - address: configItems.defaultAddress - }, - to: { - name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '), - address: subscription.email - }, - subject: util.format(_('%s: Unsubscribe Confirmed'), list.name), - encryptionKeys - }, { - html, - text, - data: { - title: list.name, - contactAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - subscribeUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '?cid=' + subscription.cid) - } - }, err => { - if (err) { - log.error('Subscription', err); - } - }); - }; - - let text = { - template: 'subscription/mail-unsubscribe-confirmed-text.hbs' - }; - - let html = { - template: 'subscription/mail-unsubscribe-confirmed-html.mjml.hbs', - layout: 'subscription/layout.mjml.hbs', - type: 'mjml' - }; - - helpers.injectCustomFormTemplates(req.query.fid || list.defaultForm, { text, html }, (err, tmpl) => { - if (err) { - return sendMail(html, text); - } - - sendMail(tmpl.html, tmpl.text); - }); - }); - }); + handleUnsubscribe(list, subscription, false, campaignId, req.ip, res, next); }); }); }); +function handleUnsubscribe(list, subscription, autoUnsubscribe, campaignId, ip, res, next) { + if ((list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) || + (autoUnsubscribe && (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM)) ) { + + subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => { + if (err) { + return next(err); + } + + // TODO: Shall we do anything with "found"? + + mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => { + if (err) { + return next(err); + } + + res.redirect('/subscription/' + list.cid + '/unsubscribed-notice'); + }); + }); + + } else if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) { + + const data = { + subscriptionId: subscription.id, + campaignId + }; + + confirmations.addConfirmation(list.id, 'unsubscribe', ip, data, (err, confirmCid) => { + if (err) { + return next(err); + } + + mailHelpers.sendConfirmUnsubscription(list, subscription.email, confirmCid, subscription, err => { + if (err) { + return next(err); + } + + res.redirect('/subscription/' + list.cid + '/confirm-unsubscription-notice'); + }); + }); + + } else { // UnsubscriptionMode.MANUAL + res.redirect('/subscription/' + list.cid + '/manual-unsubscribe-notice'); + } +} + +router.get('/:cid/confirm-subscription-notice', (req, res, next) => { + webNotice('confirm-subscription', req, res, next); +}); + +router.get('/:cid/confirm-unsubscription-notice', (req, res, next) => { + webNotice('confirm-unsubscription', req, res, next); +}); + +router.get('/:cid/subscribed-notice', (req, res, next) => { + webNotice('subscribed', req, res, next); +}); + +router.get('/:cid/updated-notice', (req, res, next) => { + webNotice('updated', req, res, next); +}); + +router.get('/:cid/unsubscribed-notice', (req, res, next) => { + webNotice('unsubscribed', req, res, next); +}); + +router.get('/:cid/manual-unsubscribe-notice', (req, res, next) => { + webNotice('manual-unsubscribe', req, res, next); +}); + router.post('/publickey', passport.parseForm, (req, res, next) => { settings.list(['pgpPassphrase', 'pgpPrivateKey'], (err, configItems) => { if (err) { @@ -934,4 +889,59 @@ router.post('/publickey', passport.parseForm, (req, res, next) => { }); }); + +function webNotice(type, req, res, next) { + lists.getByCid(req.params.cid, (err, list) => { + if (!err && !list) { + err = new Error(_('Selected list not found')); + err.status = 404; + } + + if (err) { + return next(err); + } + + settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress', 'adminEmail'], (err, configItems) => { + if (err) { + return next(err); + } + + let data = { + title: list.name, + homepage: configItems.defaultHomepage || configItems.serviceUrl, + defaultAddress: configItems.defaultAddress, + defaultPostaddress: configItems.defaultPostaddress, + contactAddress: configItems.defaultAddress, + template: { + template: 'subscription/web-' + type + '-notice.mjml.hbs', + layout: 'subscription/layout.mjml.hbs' + } + }; + + helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-' + type + '-notice', data, (err, data) => { + if (err) { + return next(err); + } + + helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { + if (err) { + return next(err); + } + + helpers.captureFlashMessages(req, res, (err, flash) => { + if (err) { + return next(err); + } + + data.isWeb = true; + data.isConfirmNotice = true; + data.flashMessages = flash; + res.send(htmlRenderer(data)); + }); + }); + }); + }); + }); +} + module.exports = router; diff --git a/services/sender.js b/services/sender.js index bd0c72cd..f04dd488 100644 --- a/services/sender.js +++ b/services/sender.js @@ -419,7 +419,7 @@ function formatMessage(message, callback) { } }, list: { - unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid + '?auto=yes') + unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid) }, subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject), html: renderedHtml, diff --git a/setup/sql/upgrade-00028.sql b/setup/sql/upgrade-00028.sql new file mode 100644 index 00000000..82d38d21 --- /dev/null +++ b/setup/sql/upgrade-00028.sql @@ -0,0 +1,33 @@ +# Header section +# Define incrementing schema version number +SET @schema_version = '28'; + +# Add unsubscription mode field to lists +ALTER TABLE `lists` ADD COLUMN `unsubscription_mode` int(11) unsigned DEFAULT 0 NOT NULL AFTER `public_subscribe`; + +# Delete all confirmations as we use different structure in "data". +DELETE FROM `confirmations`; + +# Change the name of the column to better reflect that confirmations are also used for unsubscription and email address update +# Drop email field as this does not have a clear semantics in change address. Since email is not used to search in the table, +# it can be stored in data +# Create field action to distinguish between different confirmation types (subscribe, unsubscribe, change-address) +ALTER TABLE `confirmations` CHANGE `opt_in_ip` `ip` varchar(100) DEFAULT NULL; +ALTER TABLE `confirmations` DROP `email`; +ALTER TABLE `confirmations` ADD COLUMN `action` varchar(100) NOT NULL AFTER `list`; + + +# Rename affected forms in custom_forms_data +update custom_forms_data set data_key="mail_confirm_subscription_html" where data_key="mail_confirm_html"; +update custom_forms_data set data_key="mail_confirm_subscription_text" where data_key="mail_confirm_text"; +update custom_forms_data set data_key="mail_unsubscription_confirmed_html" where data_key="mail_unsubscribe_confirmed_html"; +update custom_forms_data set data_key="mail_unsubscription_confirmed_text" where data_key="mail_unsubscribe_confirmed_text"; +update custom_forms_data set data_key="web_confirm_subscription_notice" where data_key="web_confirm_notice"; +update custom_forms_data set data_key="web_subscribed_notice" where data_key="web_subscribed"; +update custom_forms_data set data_key="web_unsubscribed_notice" where data_key="web_unsubscribe_notice"; + + +# Footer section +LOCK TABLES `settings` WRITE; +INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version; +UNLOCK TABLES; diff --git a/test/e2e/helpers/driver.js b/test/e2e/helpers/driver.js deleted file mode 100644 index a9b8444b..00000000 --- a/test/e2e/helpers/driver.js +++ /dev/null @@ -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; diff --git a/test/e2e/index.js b/test/e2e/index.js index d78f5819..5dd54d00 100644 --- a/test/e2e/index.js +++ b/test/e2e/index.js @@ -1,36 +1,30 @@ 'use strict'; -require('./helpers/exit-unless-test'); +require('./lib/exit-unless-test'); +const { mocha, driver } = require('./lib/mocha-e2e'); +const path = require('path'); global.USE_SHARED_DRIVER = true; -const driver = require('./helpers/driver'); const only = 'only'; const skip = 'skip'; - - let tests = [ - ['tests/login'], - ['tests/subscription'] + 'login', + 'subscription' ]; - - -tests = tests.filter(t => t[1] !== skip); - -if (tests.some(t => t[1] === only)) { - tests = tests.filter(t => t[1] === only); +tests = tests.map(testSpec => (testSpec.constructor === Array ? testSpec : [testSpec])); +tests = tests.filter(testSpec => testSpec[1] !== skip); +if (tests.some(testSpec => testSpec[1] === only)) { + tests = tests.filter(testSpec => testSpec[1] === only); } -describe('e2e', function() { - this.timeout(10000); +for (const testSpec of tests) { + const testPath = path.join(__dirname, 'tests', testSpec[0] + '.js'); + mocha.addFile(testPath); +} - tests.forEach(t => { - describe(t[0], () => { - require('./' + t[0]); // eslint-disable-line global-require - }); - }); - - after(() => driver.originalQuit()); +mocha.run(failures => { + process.exit(failures); // exit with non-zero status if there were failures }); diff --git a/test/e2e/helpers/config.js b/test/e2e/lib/config.js similarity index 91% rename from test/e2e/helpers/config.js rename to test/e2e/lib/config.js index 71726ff7..a9d191be 100644 --- a/test/e2e/helpers/config.js +++ b/test/e2e/lib/config.js @@ -5,6 +5,7 @@ const config = require('config'); module.exports = { app: config, baseUrl: 'http://localhost:' + config.www.port, + mailUrl: 'http://localhost:' + config.testserver.mailboxserverport, users: { admin: { username: 'admin', diff --git a/test/e2e/helpers/exit-unless-test.js b/test/e2e/lib/exit-unless-test.js similarity index 100% rename from test/e2e/helpers/exit-unless-test.js rename to test/e2e/lib/exit-unless-test.js diff --git a/test/e2e/lib/mail.js b/test/e2e/lib/mail.js new file mode 100644 index 00000000..b0f41872 --- /dev/null +++ b/test/e2e/lib/mail.js @@ -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); diff --git a/test/e2e/lib/mocha-e2e.js b/test/e2e/lib/mocha-e2e.js new file mode 100644 index 00000000..16aeba06 --- /dev/null +++ b/test/e2e/lib/mocha-e2e.js @@ -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 +}; \ No newline at end of file diff --git a/test/e2e/lib/page.js b/test/e2e/lib/page.js new file mode 100644 index 00000000..07064af7 --- /dev/null +++ b/test/e2e/lib/page.js @@ -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); diff --git a/test/e2e/lib/semaphore.js b/test/e2e/lib/semaphore.js new file mode 100644 index 00000000..7c30c900 --- /dev/null +++ b/test/e2e/lib/semaphore.js @@ -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; \ No newline at end of file diff --git a/test/e2e/lib/web.js b/test/e2e/lib/web.js new file mode 100644 index 00000000..692c0214 --- /dev/null +++ b/test/e2e/lib/web.js @@ -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); diff --git a/test/e2e/page-objects/flash.js b/test/e2e/page-objects/flash.js deleted file mode 100644 index ee42f4bd..00000000 --- a/test/e2e/page-objects/flash.js +++ /dev/null @@ -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]); - } - `); - } -}); diff --git a/test/e2e/page-objects/home.js b/test/e2e/page-objects/home.js index 2fccae24..3e33af24 100644 --- a/test/e2e/page-objects/home.js +++ b/test/e2e/page-objects/home.js @@ -1,11 +1,7 @@ 'use strict'; -const page = require('./page'); +const web = require('../lib/web'); -module.exports = driver => Object.assign(page(driver), { - url: '/', - elementToWaitFor: 'body', - elements: { - body: 'body.page--home' - } +module.exports = web({ + url: '/' }); diff --git a/test/e2e/page-objects/page.js b/test/e2e/page-objects/page.js deleted file mode 100644 index ece926ad..00000000 --- a/test/e2e/page-objects/page.js +++ /dev/null @@ -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; - `); - } -}); diff --git a/test/e2e/page-objects/subscription.js b/test/e2e/page-objects/subscription.js index 20b56f6f..1d431b49 100644 --- a/test/e2e/page-objects/subscription.js +++ b/test/e2e/page-objects/subscription.js @@ -1,84 +1,132 @@ 'use strict'; -const config = require('../helpers/config'); -const page = require('./page'); +const config = require('../lib/config'); +const web = require('../lib/web'); +const mail = require('../lib/mail'); -const web = { - enterEmail(value) { - this.element('emailInput').clear(); - return this.element('emailInput').sendKeys(value); - } -}; +module.exports = list => ({ -const mail = { - navigate(address) { - this.driver.sleep(100); - this.driver.navigate().to(`http://localhost:${config.app.testserver.mailboxserverport}/${address}`); - return this.waitUntilVisible(); - } -}; - -module.exports = (driver, list) => ({ - - webSubscribe: Object.assign(page(driver), web, { + webSubscribe: web({ url: `/subscription/${list.cid}`, - elementToWaitFor: 'form', + elementsToWaitFor: ['form'], + textsToWaitFor: ['Subscribe to list'], elements: { form: `form[action="/subscription/${list.cid}/subscribe"]`, emailInput: '#main-form input[name="email"]', + firstNameInput: '#main-form input[name="first-name"]', + lastNameInput: '#main-form input[name="last-name"]', submitButton: 'a[href="#submit"]' } }), - webConfirmSubscriptionNotice: Object.assign(page(driver), web, { - url: `/subscription/${list.cid}/confirm-notice`, - elementToWaitFor: 'homepageButton', + webConfirmSubscriptionNotice: web({ + url: `/subscription/${list.cid}/confirm-subscription-notice`, + textsToWaitFor: ['We need to confirm your email address'] + }), + + mailConfirmSubscription: mail({ + elementsToWaitFor: ['confirmLink'], + textsToWaitFor: ['Please Confirm Subscription'], elements: { - homepageButton: `a[href="${config.settings['default-homepage']}"]` + confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/subscribe/"]` } }), - mailConfirmSubscription: Object.assign(page(driver), mail, { - elementToWaitFor: 'confirmLink', + mailAlreadySubscribed: mail({ + elementsToWaitFor: ['unsubscribeLink'], + textsToWaitFor: ['Email address already registered'], elements: { - confirmLink: `a[href^="${config.settings['service-url']}subscription/subscribe/"]` + unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`, + manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]` + }, + links: { + unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`, + manageLink: `/subscription/${list.cid}/manage/:ucid` } }), - webSubscribedNotice: Object.assign(page(driver), web, { - elementToWaitFor: 'homepageButton', + webSubscribedNotice: web({ + url: `/subscription/${list.cid}/subscribed-notice`, + textsToWaitFor: ['Subscription Confirmed'] + }), + + mailSubscriptionConfirmed: mail({ + elementsToWaitFor: ['unsubscribeLink'], + textsToWaitFor: ['Subscription Confirmed'], elements: { - homepageButton: 'a[href^="https://mailtrain.org"]' + unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`, + manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]` + }, + links: { + unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`, + manageLink: `/subscription/${list.cid}/manage/:ucid` } }), - mailSubscriptionConfirmed: Object.assign(page(driver), mail, { - elementToWaitFor: 'unsubscribeLink', + webManage: web({ + url: `/subscription/${list.cid}/manage/:ucid`, + elementsToWaitFor: ['form'], + textsToWaitFor: ['Update Your Preferences'], elements: { - unsubscribeLink: 'a[href*="/unsubscribe/"]', - manageLink: 'a[href*="/manage/"]' + form: `form[action="/subscription/${list.cid}/manage"]`, + emailInput: '#main-form input[name="email"]', + firstNameInput: '#main-form input[name="first-name"]', + lastNameInput: '#main-form input[name="last-name"]', + submitButton: 'a[href="#submit"]', + manageAddressLink: `a[href^="/subscription/${list.cid}/manage-address/"]` + }, + links: { + manageAddressLink: `/subscription/${list.cid}/manage-address/:ucid` } }), - webUnsubscribe: Object.assign(page(driver), web, { - elementToWaitFor: 'submitButton', + webManageAddress: web({ + url: `/subscription/${list.cid}/manage-address/:ucid`, + elementsToWaitFor: ['form'], + textsToWaitFor: ['Update Your Email Address'], elements: { - submitButton: 'a[href="#submit"]' + form: `form[action="/subscription/${list.cid}/manage-address"]`, + emailInput: '#main-form input[name="email"]', + emailNewInput: '#main-form input[name="email-new"]', + submitButton: 'a[href="#submit"]', } }), - webUnsubscribedNotice: Object.assign(page(driver), web, { - elementToWaitFor: 'homepageButton', + mailConfirmAddressChange: mail({ + elementsToWaitFor: ['confirmLink'], + textsToWaitFor: ['Please Confirm Subscription Address Change'], elements: { - homepageButton: 'a[href^="https://mailtrain.org"]' + confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/change-address/"]` } }), - mailUnsubscriptionConfirmed: Object.assign(page(driver), mail, { - elementToWaitFor: 'resubscribeLink', + webUpdatedNotice: web({ + url: `/subscription/${list.cid}/updated-notice`, + textsToWaitFor: ['Profile Updated'], + }), + + webUnsubscribedNotice: web({ + url: `/subscription/${list.cid}/unsubscribed-notice`, + textsToWaitFor: ['Unsubscribe Successful'], + }), + + mailUnsubscriptionConfirmed: mail({ + elementsToWaitFor: ['resubscribeLink'], + textsToWaitFor: ['You Are Now Unsubscribed'], elements: { resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]` } - }) + }), + + /* + webUnsubscribe: web({ // FIXME + elementsToWaitFor: ['submitButton'], + elements: { + submitButton: 'a[href="#submit"]' + } + }), + +*/ }); + diff --git a/test/e2e/page-objects/users.js b/test/e2e/page-objects/user.js similarity index 50% rename from test/e2e/page-objects/users.js rename to test/e2e/page-objects/user.js index 27f7d82b..52f8377f 100644 --- a/test/e2e/page-objects/users.js +++ b/test/e2e/page-objects/user.js @@ -1,32 +1,29 @@ 'use strict'; -const page = require('./page'); +const web = require('../lib/web'); -module.exports = driver => ({ - - login: Object.assign(page(driver), { +module.exports = { + login: web({ url: '/users/login', - elementToWaitFor: 'submitButton', + elementsToWaitFor: ['submitButton'], elements: { usernameInput: 'form[action="/users/login"] input[name="username"]', passwordInput: 'form[action="/users/login"] input[name="password"]', submitButton: 'form[action="/users/login"] [type=submit]' - }, - enterUsername(value) { - // this.element('usernameInput').clear(); - return this.element('usernameInput').sendKeys(value); - }, - enterPassword(value) { - return this.element('passwordInput').sendKeys(value); } }), - account: Object.assign(page(driver), { + logout: web({ + requestUrl: '/users/logout', + url: '/' + }), + + account: web({ url: '/users/account', - elementToWaitFor: 'emailInput', + elementsToWaitFor: ['form'], elements: { + form: `form[action="/users/account"]`, emailInput: 'form[action="/users/account"] input[name="email"]' } - }) - -}); + }), +}; diff --git a/test/e2e/tests/login.js b/test/e2e/tests/login.js index 238b5def..571389be 100644 --- a/test/e2e/tests/login.js +++ b/test/e2e/tests/login.js @@ -1,57 +1,68 @@ 'use strict'; -const config = require('../helpers/config'); +const config = require('../lib/config'); +const { useCase, step, driver } = require('../lib/mocha-e2e'); const expect = require('chai').expect; -const driver = require('../helpers/driver'); -const home = require('../page-objects/home')(driver); -const flash = require('../page-objects/flash')(driver); -const { - login, - account -} = require('../page-objects/users')(driver); -describe('login', function() { - this.timeout(10000); +const page = require('../page-objects/user'); +const home = require('../page-objects/home'); +suite('Login use-cases', function() { before(() => driver.manage().deleteAllCookies()); - it('can access home page', async () => { + test('User can access home page', async () => { await home.navigate(); }); - it('can not access restricted content', async () => { - driver.navigate().to(config.baseUrl + '/settings'); - flash.waitUntilVisible(); - expect(await flash.getText()).to.contain('Need to be logged in to access restricted content'); - await flash.clear(); + test('Anonymous user cannot access restricted content', async () => { + await driver.navigate().to(config.baseUrl + '/settings'); + await page.login.waitUntilVisible(); + await page.login.waitForFlash(); + expect(await page.login.getFlash()).to.contain('Need to be logged in to access restricted content'); }); - it('can not login with false credentials', async () => { - login.enterUsername(config.users.admin.username); - login.enterPassword('invalid'); - login.submit(); - flash.waitUntilVisible(); - expect(await flash.getText()).to.contain('Incorrect username or password'); - await flash.clear(); + useCase('Login (invalid credential)', async () => { + await step('User navigates to the login page.', async () => { + await page.login.navigate(); + }); + + await step('User fills in the user name and incorrect password.', async () => { + await page.login.setValue('usernameInput', config.users.admin.username); + await page.login.setValue('passwordInput', 'invalid'); + await page.login.submit(); + }); + + 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 () => { - login.enterUsername(config.users.admin.username); - login.enterPassword(config.users.admin.password); - login.submit(); - flash.waitUntilVisible(); - expect(await flash.getText()).to.contain('Logged in as admin'); - }); + useCase('Login and logout', async () => { + await step('User navigates to the login page.', async () => { + await page.login.navigate(); + }); - it('can access account page as admin', async () => { - await account.navigate(); - }); + await step('User fills in the user name and password.', async () => { + await page.login.setValue('usernameInput', config.users.admin.username); + await page.login.setValue('passwordInput', config.users.admin.password); + await page.login.submit(); + }); - it('can logout', async () => { - driver.navigate().to(config.baseUrl + '/users/logout'); - flash.waitUntilVisible(); - expect(await flash.getText()).to.contain('logged out'); - }); + await step('System shows the home page and a flash notice that user has been logged in.', async () => { + await home.waitUntilVisibleAfterRefresh(); + await home.waitForFlash(); + expect(await home.getFlash()).to.contain('Logged in as admin'); + }); - 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'); + }); + }); }); diff --git a/test/e2e/tests/subscription.js b/test/e2e/tests/subscription.js index 2e694aa8..fec05937 100644 --- a/test/e2e/tests/subscription.js +++ b/test/e2e/tests/subscription.js @@ -1,101 +1,248 @@ 'use strict'; -const config = require('../helpers/config'); +const config = require('../lib/config'); +const { useCase, step, precondition, driver } = require('../lib/mocha-e2e'); const shortid = require('shortid'); const expect = require('chai').expect; -const driver = require('../helpers/driver'); -const page = require('../page-objects/page')(driver); -const flash = require('../page-objects/flash')(driver); +const page = require('../page-objects/subscription')(config.lists.one); -const { - webSubscribe, - webConfirmSubscriptionNotice, - mailConfirmSubscription, - webSubscribedNotice, - mailSubscriptionConfirmed, - webUnsubscribe, - webUnsubscribedNotice, - mailUnsubscriptionConfirmed -} = require('../page-objects/subscription')(driver, config.lists.one); +function generateEmail() { + return 'keep.' + shortid.generate() + '@mailtrain.org'; +} -const testuser = { - email: 'keep.' + shortid.generate() + '@mailtrain.org' -}; +async function subscribe(subscription) { + await step('User navigates to list subscription page.', async () => { + await page.webSubscribe.navigate(); + }); -// console.log(testuser.email); + await step('User submits a valid email and other subscription info.', async () => { + await page.webSubscribe.setValue('emailInput', subscription.email); -describe('subscribe (list one)', function() { - this.timeout(10000); + if (subscription.firstName) { + await page.webSubscribe.setValue('firstNameInput', subscription.firstName); + } + if (subscription.lastName) { + await page.webSubscribe.setValue('lastNameInput', subscription.lastName); + } + + await page.webSubscribe.submit(); + }); + + await step('System shows a notice that further instructions are in the email.', async () => { + await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh(); + }); + + await step('System sends an email with a link to confirm the subscription.', async () => { + await page.mailConfirmSubscription.fetchMail(subscription.email); + }); + + await step('User clicks confirm subscription in the email', async () => { + await page.mailConfirmSubscription.click('confirmLink'); + }); + + await step('System shows a notice that subscription has been confirmed.', async () => { + await page.webSubscribedNotice.waitUntilVisibleAfterRefresh(); + }); + + await step('System sends an email with subscription confirmation.', async () => { + await page.mailSubscriptionConfirmed.fetchMail(subscription.email); + subscription.unsubscribeLink = await page.mailSubscriptionConfirmed.getHref('unsubscribeLink'); + subscription.manageLink = await page.mailSubscriptionConfirmed.getHref('manageLink'); + + const unsubscribeParams = await page.mailSubscriptionConfirmed.getLinkParams('unsubscribeLink'); + const manageParams = await page.mailSubscriptionConfirmed.getLinkParams('manageLink'); + expect(unsubscribeParams.ucid).to.equal(manageParams.ucid); + subscription.ucid = unsubscribeParams.ucid; + }); + + return subscription; +} + +async function subscriptionExistsPrecondition(subscription) { + await precondition('Subscription exists', 'Subscription to a public list (main scenario)', async () => { + await subscribe(subscription); + }); + return subscription; +} + +suite('Subscription use-cases', function() { before(() => driver.manage().deleteAllCookies()); - it('visits web-subscribe', async () => { - await webSubscribe.navigate(); + useCase('Subscription to a public list (main scenario)', async () => { + await subscribe({ + email: generateEmail() + }); }); - it('submits invalid email (error)', async () => { - webSubscribe.enterEmail('foo@bar.nope'); - webSubscribe.submit(); - flash.waitUntilVisible(); - expect(await flash.getText()).to.contain('Invalid email address'); + useCase('Subscription to a public list (invalid email)', async () => { + await step('User navigates to list subscribe page', async () => { + await page.webSubscribe.navigate(); + }); + + 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 () => { - webSubscribe.enterEmail(testuser.email); - await webSubscribe.submit(); + useCase('Subscription to a public list (email already registered)', async () => { + const subscription = await subscriptionExistsPrecondition({ + 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 () => { - webConfirmSubscriptionNotice.waitUntilVisible(); - expect(await page.containsText('Almost Finished')).to.be.true; + useCase('Subscription to a non-public list'); + + useCase('Change profile info', async () => { + const subscription = await subscriptionExistsPrecondition({ + email: generateEmail(), + firstName: 'John', + lastName: 'Doe' + }); + + await step('User clicks the manage subscription button.', async () => { + await page.mailSubscriptionConfirmed.click('manageLink'); + }); + + await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => { + await page.webManage.waitUntilVisibleAfterRefresh(); + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName); + expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName); + }); + + await step('User enters another name and submits the form.', async () => { + subscription.firstName = 'Adam'; + subscription.lastName = 'B'; + await page.webManage.setValue('firstNameInput', subscription.firstName); + await page.webManage.setValue('lastNameInput', subscription.lastName); + await page.webManage.submit(); + }); + + await step('Systems shows a notice that profile has been updated.', async () => { + await page.webUpdatedNotice.waitUntilVisibleAfterRefresh(); + }); + + await step('User navigates to manage subscription again.', async () => { + // await page.webManage.navigate(subscription.manageLink); + await page.webManage.navigate({ ucid: subscription.ucid }); + }); + + await step('Systems shows a form with the changes made previously.', async () => { + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName); + expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName); + }); }); - it('receives mail-confirm-subscription', async () => { - mailConfirmSubscription.navigate(testuser.email); - expect(await page.containsText('Please Confirm Subscription')).to.be.true; + useCase('Change email', async () => { + const subscription = await subscriptionExistsPrecondition({ + email: generateEmail(), + firstName: 'John', + lastName: 'Doe' + }); + + await step('User clicks the manage subscription button.', async () => { + await page.mailSubscriptionConfirmed.click('manageLink'); + }); + + await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => { + await page.webManage.waitUntilVisibleAfterRefresh(); + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName); + expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName); + }); + + await step('User clicks the change address button.', async () => { + await page.webManage.click('manageAddressLink'); + }); + + await step('Systems shows a form to change email.', async () => { + await page.webManageAddress.waitUntilVisibleAfterRefresh(); + }); + + await step('User fills in a new email address and submits the form.', async () => { + subscription.email = generateEmail(); + await page.webManageAddress.setValue('emailNewInput', subscription.email); + await page.webManageAddress.submit(); + }); + + await step('System goes back to the profile form and shows a flash notice that further instructions are in the email.', async () => { + await page.webManage.waitUntilVisibleAfterRefresh(); + await page.webManage.waitForFlash(); + expect(await page.webManage.getFlash()).to.contain('An email with further instructions has been sent to the provided address'); + }); + + await step('System sends an email with a link to confirm the address change.', async () => { + await page.mailConfirmAddressChange.fetchMail(subscription.email); + }); + + await step('User clicks confirm subscription in the email', async () => { + await page.mailConfirmAddressChange.click('confirmLink'); + }); + + await step('System shows the profile form with a flash notice that address has been changed.', async () => { + await page.webManage.waitUntilVisibleAfterRefresh(); + await page.webManage.waitForFlash(); + expect(await page.webManage.getFlash()).to.contain('Email address changed'); + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + }); + + await step('System sends an email with subscription confirmation.', async () => { + await page.mailSubscriptionConfirmed.fetchMail(subscription.email); + }); }); - it('clicks confirm subscription', async () => { - await mailConfirmSubscription.click('confirmLink'); + useCase('Unsubscription from list #1 (one-step, no form).', async () => { + const subscription = await subscriptionExistsPrecondition({ + email: generateEmail() + }); + + await step('User clicks the unsubscribe button.', async () => { + await page.mailSubscriptionConfirmed.click('unsubscribeLink'); + }); + + await step('System shows a notice that confirms unsubscription.', async () => { + await page.webUnsubscribedNotice.waitUntilVisibleAfterRefresh(); + }); + + await step('System sends an email that confirms unsubscription.', async () => { + await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email); + }); }); - it('sees web-subscribed-notice', async () => { - webSubscribedNotice.waitUntilVisible(); - expect(await page.containsText('Subscription Confirmed')).to.be.true; - }); + useCase('Unsubscription from list #2 (one-step, with form).'); - it('receives mail-subscription-confirmed', async () => { - mailSubscriptionConfirmed.navigate(testuser.email); - expect(await page.containsText('Subscription Confirmed')).to.be.true; - }); -}); - -describe('unsubscribe (list one)', function() { - this.timeout(10000); - - it('clicks unsubscribe', async () => { - await mailSubscriptionConfirmed.click('unsubscribeLink'); - }); - - it('sees web-unsubscribe', async () => { - webUnsubscribe.waitUntilVisible(); - expect(await page.containsText('Unsubscribe')).to.be.true; - }); - - it('clicks confirm unsubscription', async () => { - await webUnsubscribe.submit(); - }); - - it('sees web-unsubscribed-notice', async () => { - webUnsubscribedNotice.waitUntilVisible(); - expect(await page.containsText('Unsubscribe Successful')).to.be.true; - }); - - it('receives mail-unsubscription-confirmed', async () => { - mailUnsubscriptionConfirmed.navigate(testuser.email); - expect(await page.containsText('You Are Now Unsubscribed')).to.be.true; - }); - - after(() => driver.quit()); + useCase('Unsubscription from list #3 (two-step, no form).'); + + useCase('Unsubscription from list #4 (two-step, with form).'); + + useCase('Unsubscription from list #5 (manual unsubscribe).'); + + useCase('Resubscription.'); // This one is supposed to check that values pre-filled in resubscription (i.e. the re-subscribe link in unsubscription confirmation) are the same as the ones used before. }); diff --git a/views/lists/create.hbs b/views/lists/create.hbs index 6cfaa7b8..ba37f4e3 100644 --- a/views/lists/create.hbs +++ b/views/lists/create.hbs @@ -26,11 +26,27 @@
{{#translate}}Subscribe{{/translate}} | - {{#translate}}Confirm Notice{{/translate}} + {{#translate}}Confirm Subscription Notice{{/translate}} + | + {{#translate}}Confirm Unsubscription Notice{{/translate}} + | + {{#translate}}Subscribed Notice{{/translate}} | {{#translate}}Updated Notice{{/translate}} | - {{#translate}}Unsubscribed Notice{{/translate}} + {{#translate}}Unsubscribed Notice{{/translate}} + | + {{#translate}}Manual Unsubscribe Notice{{/translate}} {{#if testUsers}} + | + {{#translate}}Unsubscribe{{/translate}} | {{#translate}}Manage{{/translate}} | diff --git a/views/lists/forms/forms.hbs b/views/lists/forms/forms.hbs index 9bc3ab92..367d0ebf 100644 --- a/views/lists/forms/forms.hbs +++ b/views/lists/forms/forms.hbs @@ -68,7 +68,7 @@