From 3783d7c2cece94abc8622ed99422648697849c5f Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sun, 30 Apr 2017 10:51:47 -0400 Subject: [PATCH 01/11] Halfway through extending subscriptions by selectable unsubscription process. Also contains changes towards better handling of scenarios when address is already subscribed. --- lib/models/forms.js | 28 +- lib/models/lists.js | 116 +++--- lib/models/subscriptions.js | 337 ++++++++++----- lib/tools.js | 10 +- routes/api.js | 2 + routes/forms.js | 57 ++- routes/lists.js | 28 ++ routes/subscription.js | 385 +++++++----------- setup/sql/upgrade-00028.sql | 24 ++ views/lists/create.hbs | 26 +- views/lists/edit.hbs | 26 +- views/lists/forms/edit.hbs | 8 +- views/lists/forms/forms.hbs | 2 +- .../mail-already-subscribed-html.mjml.hbs | 24 ++ .../mail-already-subscribed-text.hbs | 18 + .../mail-confirm-address-change-html.mjml.hbs | 17 + .../mail-confirm-address-change-text.hbs | 10 + ...> mail-confirm-subscription-html.mjml.hbs} | 0 ...hbs => mail-confirm-subscription-text.hbs} | 0 .../mail-confirm-unsubscription-html.mjml.hbs | 17 + .../mail-confirm-unsubscription-text.hbs | 10 + ...il-unsubscription-confirmed-html.mjml.hbs} | 0 ...=> mail-unsubscription-confirmed-text.hbs} | 0 ... web-confirm-subscription-notice.mjml.hbs} | 0 ...web-confirm-unsubscription-notice.mjml.hbs | 13 + ...jml.hbs => web-subscribed-notice.mjml.hbs} | 0 ...l.hbs => web-unsubscribed-notice.mjml.hbs} | 0 27 files changed, 727 insertions(+), 431 deletions(-) create mode 100644 setup/sql/upgrade-00028.sql create mode 100644 views/subscription/mail-already-subscribed-html.mjml.hbs create mode 100644 views/subscription/mail-already-subscribed-text.hbs create mode 100644 views/subscription/mail-confirm-address-change-html.mjml.hbs create mode 100644 views/subscription/mail-confirm-address-change-text.hbs rename views/subscription/{mail-confirm-html.mjml.hbs => mail-confirm-subscription-html.mjml.hbs} (100%) rename views/subscription/{mail-confirm-text.hbs => mail-confirm-subscription-text.hbs} (100%) create mode 100644 views/subscription/mail-confirm-unsubscription-html.mjml.hbs create mode 100644 views/subscription/mail-confirm-unsubscription-text.hbs rename views/subscription/{mail-unsubscribe-confirmed-html.mjml.hbs => mail-unsubscription-confirmed-html.mjml.hbs} (100%) rename views/subscription/{mail-unsubscribe-confirmed-text.hbs => mail-unsubscription-confirmed-text.hbs} (100%) rename views/subscription/{web-confirm-notice.mjml.hbs => web-confirm-subscription-notice.mjml.hbs} (100%) create mode 100644 views/subscription/web-confirm-unsubscription-notice.mjml.hbs rename views/subscription/{web-subscribed.mjml.hbs => web-subscribed-notice.mjml.hbs} (100%) rename views/subscription/{web-unsubscribe-notice.mjml.hbs => web-unsubscribed-notice.mjml.hbs} (100%) diff --git a/lib/models/forms.js b/lib/models/forms.js index 940f5483..802e5706 100644 --- a/lib/models/forms.js +++ b/lib/models/forms.js @@ -14,22 +14,30 @@ let allowedKeys = [ 'fields_shown_on_manage', 'layout', 'form_input_style', - 'mail_confirm_html', - 'mail_confirm_text', + 'web_subscribe', + 'web_confirm_subscription_notice', + 'mail_confirm_subscription_html', + 'mail_confirm_subscription_text', + 'mail_already_subscribed_html', + 'mail_already_subscribed_text', + 'web_subscribed_notice', 'mail_subscription_confirmed_html', 'mail_subscription_confirmed_text', - 'mail_unsubscribe_confirmed_html', - 'mail_unsubscribe_confirmed_text', - 'web_confirm_notice', - 'web_manage_address', 'web_manage', - 'web_subscribe', - 'web_subscribed', - 'web_unsubscribe_notice', + 'web_manage_address', + 'web_updated_notice', 'web_unsubscribe', - 'web_updated_notice' + 'web_confirm_unsubscription_notice', + 'mail_confirm_unsubscription_html', + 'mail_confirm_unsubscription_text', + 'mail_confirm_address_change_html', + 'mail_confirm_address_change_text', + 'web_unsubscribed_notice', + 'mail_unsubscription_confirmed_html', + 'mail_unsubscription_confirmed_text' ]; + module.exports.list = (listId, callback) => { listId = Number(listId) || 0; diff --git a/lib/models/lists.js b/lib/models/lists.js index 28304722..7ee7dcc6 100644 --- a/lib/models/lists.js +++ b/lib/models/lists.js @@ -7,7 +7,16 @@ let segments = require('./segments'); let _ = require('../translate')._; let tableHelpers = require('../table-helpers'); -let allowedKeys = ['description', 'default_form', 'public_subscribe']; +const UnsubscriptionMode = { + ONE_STEP: 0, + TWO_STEP: 1, + MANUAL: 2, + MAX: 3 +}; + +module.exports.UnsubscriptionMode = UnsubscriptionMode; + +let allowedKeys = ['description', 'default_form', 'public_subscribe', 'unsubscription_mode']; module.exports.list = (start, limit, callback) => { tableHelpers.list('lists', ['*'], 'name', null, start, limit, callback); @@ -99,6 +108,63 @@ module.exports.get = (id, callback) => { }); }; +module.exports.update = (id, updates, callback) => { + updates = updates || {}; + id = Number(id) || 0; + + if (id < 1) { + return callback(new Error(_('Missing List ID'))); + } + + const data = tools.convertKeys(updates); + + const keys = []; + const values = []; + + // The update can be only partial when executed from forms/:list + if (!data.customFormChangeOnly) { + data.publicSubscribe = data.publicSubscribe ? 1 : 0; + data.unsubscriptionMode = Number(data.unsubscriptionMode); + + let name = (data.name || '').toString().trim(); + + if (!name) { + return callback(new Error(_('List Name must be set'))); + } + + keys.push('name'); + values.push(name); + } + + Object.keys(data).forEach(key => { + let value = data[key].toString().trim(); + key = tools.toDbKey(key); + if (key === 'description') { + value = tools.purifyHTML(value); + } + if (allowedKeys.indexOf(key) >= 0) { + keys.push(key); + values.push(value); + } + }); + + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + values.push(id); + + connection.query('UPDATE lists SET ' + keys.map(key => key + '=?').join(', ') + ' WHERE id=? LIMIT 1', values, (err, result) => { + connection.release(); + if (err) { + return callback(err); + } + return callback(null, result && result.affectedRows || false); + }); + }); +}; + module.exports.create = (list, callback) => { let data = tools.convertKeys(list); @@ -157,54 +223,6 @@ module.exports.create = (list, callback) => { }); }; -module.exports.update = (id, updates, callback) => { - updates = updates || {}; - id = Number(id) || 0; - - let data = tools.convertKeys(updates); - data.publicSubscribe = data.publicSubscribe ? 1 : 0; - - let name = (data.name || '').toString().trim(); - let keys = ['name']; - let values = [name]; - - if (id < 1) { - return callback(new Error(_('Missing List ID'))); - } - - if (!name) { - return callback(new Error(_('List Name must be set'))); - } - - Object.keys(data).forEach(key => { - let value = data[key].toString().trim(); - key = tools.toDbKey(key); - if (key === 'description') { - value = tools.purifyHTML(value); - } - if (allowedKeys.indexOf(key) >= 0) { - keys.push(key); - values.push(value); - } - }); - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - values.push(id); - - connection.query('UPDATE lists SET ' + keys.map(key => key + '=?').join(', ') + ' WHERE id=? LIMIT 1', values, (err, result) => { - connection.release(); - if (err) { - return callback(err); - } - return callback(null, result && result.affectedRows || false); - }); - }); -}; - module.exports.delete = (id, callback) => { id = Number(id) || 0; diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index 99d32be0..57169257 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -88,108 +88,109 @@ module.exports.filter = (listId, request, columns, segmentId, callback) => { }; -module.exports.addConfirmation = (list, email, optInIp, data, callback) => { +module.exports.addConfirmation = (list, email, ip, data, callback) => { let cid = shortid.generate(); - tools.validateEmail(email, false, err => { + db.getConnection((err, connection) => { if (err) { return callback(err); } - db.getConnection((err, connection) => { + let query = 'INSERT INTO confirmations (cid, list, email, ip, data) VALUES (?,?,?,?,?)'; + connection.query(query, [cid, list.id, email, ip, JSON.stringify(data || {})], (err, result) => { + connection.release(); if (err) { return callback(err); } - let query = 'INSERT INTO confirmations (cid, list, email, opt_in_ip, data) VALUES (?,?,?,?,?)'; - connection.query(query, [cid, list.id, email, optInIp, JSON.stringify(data || {})], (err, result) => { - connection.release(); + if (!result || !result.affectedRows) { + return callback(null, false); + } + + fields.list(list.id, (err, fieldList) => { if (err) { return callback(err); } - if (!result || !result.affectedRows) { - return callback(null, false); - } + let encryptionKeys = []; + fields.getRow(fieldList, data).forEach(field => { + if (field.type === 'gpg' && field.value) { + encryptionKeys.push(field.value.trim()); + } + }); - fields.list(list.id, (err, fieldList) => { + settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl'], (err, configItems) => { if (err) { return callback(err); } - let encryptionKeys = []; - fields.getRow(fieldList, data).forEach(field => { - if (field.type === 'gpg' && field.value) { - encryptionKeys.push(field.value.trim()); - } - }); - - settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl'], (err, configItems) => { - if (err) { - return callback(err); + setImmediate(() => { + if (data._skip) { + log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data)); + return; } - setImmediate(() => { - if (data._skip) { - log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data)); - return; + // FIXME - move to router + const mailOpts = { + subject: _('%s: Please Confirm Subscription'), + confirmUrlRoute: '/subscription/confirm/', + templateType: 'subscription' + }; + + let sendMail = (html, text) => { + mailer.sendMail({ + from: { + name: configItems.defaultFrom, + address: configItems.defaultAddress + }, + to: { + name: [].concat(data.firstName || []).concat(data.lastName || []).join(' '), + address: email + }, + subject: util.format(mailOpts.subject, list.name), + encryptionKeys + }, { + html, + text, + data: { + title: list.name, + contactAddress: configItems.defaultAddress, + defaultPostaddress: configItems.defaultPostaddress, + confirmUrl: urllib.resolve(configItems.serviceUrl, mailOpts.confirmUrlRoute + cid) + } + }, err => { + if (err) { + log.error('Subscription', err); + } + }); + }; + + let text = { + template: 'subscription/mail-confirm-' + mailOpts.templateType + '-text.hbs' + }; + + let html = { + template: 'subscription/mail-confirm-' + mailOpts.templateType + '-html.mjml.hbs', + layout: 'subscription/layout.mjml.hbs', + type: 'mjml' + }; + + helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => { + if (err) { + return sendMail(html, text); } - let sendMail = (html, text) => { - mailer.sendMail({ - from: { - name: configItems.defaultFrom, - address: configItems.defaultAddress - }, - to: { - name: [].concat(data.firstName || []).concat(data.lastName || []).join(' '), - address: email - }, - subject: util.format(_('%s: Please Confirm Subscription'), list.name), - encryptionKeys - }, { - html, - text, - data: { - title: list.name, - contactAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - confirmUrl: urllib.resolve(configItems.serviceUrl, '/subscription/subscribe/' + cid) - } - }, err => { - if (err) { - log.error('Subscription', err); - } - }); - }; - - let text = { - template: 'subscription/mail-confirm-text.hbs' - }; - - let html = { - template: 'subscription/mail-confirm-html.mjml.hbs', - layout: 'subscription/layout.mjml.hbs', - type: 'mjml' - }; - - helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => { - if (err) { - return sendMail(html, text); - } - - sendMail(tmpl.html, tmpl.text); - }); + sendMail(tmpl.html, tmpl.text); }); - return callback(null, cid); }); + return callback(null, cid); }); }); }); }); }; -module.exports.subscribe = (cid, optInIp, callback) => { +module.exports.processConfirmation = (cid, ip, callback) => { db.getConnection((err, connection) => { if (err) { return callback(err); @@ -215,7 +216,11 @@ module.exports.subscribe = (cid, optInIp, callback) => { subscription = {}; } - if (subscription.action === 'update' && subscription.subscriber) { + if (subscription._action === 'update') { + if (!subscription.subscriber) { // Something went terribly wrong and we don't have data that we have originally provided + return callback(new Error(_('Subscriber info corrupted or missing'))); + } + // update email address instead of adding new db.getConnection((err, connection) => { if (err) { @@ -230,45 +235,57 @@ module.exports.subscribe = (cid, optInIp, callback) => { } connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => { connection.release(); - // reload full data from db in case it was an update, not insert - return module.exports.getById(listId, subscription.subscriber, callback); + return module.exports.getById(listId, subscription.subscriber, (err, subscriptionData) => { + return callback(err, subscriptionData, subscription._action); + }); }); }); }); return; - } - subscription.cid = cid; - subscription.list = listId; - subscription.email = email; + } else if (subscription._action === 'unsubscribe') { + // TODO + return; - let optInCountry = geoip.lookupCountry(optInIp) || null; - module.exports.insert(listId, { - email, - cid, - optInIp, - optInCountry, - status: 1 - }, subscription, (err, result) => { - if (err) { - return callback(err); - } + } else if (subscription._action === 'subscribe') { + subscription.cid = cid; + subscription.list = listId; + subscription.email = email; - if (!result.entryId) { - return callback(new Error(_('Could not save subscription'))); - } - - db.getConnection((err, connection) => { + let optInCountry = geoip.lookupCountry(ip) || null; + module.exports.insert(listId, { + email, + cid, + optInIp: ip, + optInCountry, + status: 1 + }, subscription, (err, result) => { if (err) { return callback(err); } - connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => { - connection.release(); - // reload full data from db in case it was an update, not insert - return module.exports.getById(listId, result.entryId, callback); + + if (!result.entryId) { + return callback(new Error(_('Could not save subscription'))); + } + + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => { + connection.release(); + // reload full data from db in case it was an update, not insert + return module.exports.getById(listId, result.entryId, (err, subscriptionData) => { + return callback(err, subscriptionData, subscription._action); + }); + }); }); }); - }); + + } else { + return callback(new Error(util.format(_('Subscription request corrupted - action: %s'), subscription._action))); + } + }); }); }; @@ -307,6 +324,7 @@ module.exports.insert = (listId, meta, subscription, callback) => { } }); + // FIXME - see issue #218 fields.getValues(fields.getRow(fieldList, subscription, true, true, !!meta.partial), true).forEach(field => { keys.push(field.key); values.push(field.value); @@ -377,6 +395,8 @@ module.exports.insert = (listId, meta, subscription, callback) => { queryArgs = values.concat(existing.id); query = 'UPDATE `subscription__' + listId + '` SET ' + keys.map(key => '`' + key + '`=?') + ' WHERE id=? LIMIT 1'; } + console.log(query); + console.log(queryArgs); connection.query(query, queryArgs, (err, result) => { if (err) { @@ -1076,7 +1096,7 @@ module.exports.listImports = (listId, callback) => { }; -module.exports.updateAddress = (list, cid, updates, optInIp, callback) => { +module.exports.updateAddress = (list, cid, updates, ip, callback) => { updates = tools.convertKeys(updates); cid = (cid || '').toString().trim(); @@ -1128,11 +1148,13 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => { } if (rows && rows[0] && rows[0].id) { + + return callback(new Error(_('This address is already registered by someone else'))); } - module.exports.addConfirmation(list, emailNew, optInIp, { - action: 'update', + module.exports.addConfirmation(list, emailNew, ip, { + _action: 'update', cid, subscriber: old.id, emailOld: old.email @@ -1142,3 +1164,114 @@ module.exports.updateAddress = (list, cid, updates, optInIp, callback) => { }); }); }; + + +module.exports.sendMail = (listId, email, template, subject, mailOpts, subscription, callback) => { + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + lists.get(listId, (err, list) => { + if (!err && !list) { + err = new Error(_('Selected list not found')); + err.status = 404; + } + + if (err) { + return next(err); + } + + fields.list(list.id, (err, fieldList) => { + if (err) { + return callback(err); + } + + let encryptionKeys = []; + fields.getRow(fieldList, subscription).forEach(field => { + if (field.type === 'gpg' && field.value) { + encryptionKeys.push(field.value.trim()); + } + }); + + settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl'], (err, configItems) => { + if (err) { + return callback(err); + } + + const data = { + title: list.name, + contactAddress: configItems.defaultAddress, + defaultPostaddress: configItems.defaultPostaddress, + }; + + if (mailOpts.confirmUrlRoute) { + data.confirmUrl = urllib.resolve(configItems.serviceUrl, mailOpts.confirmUrlRoute + cid) + } + + function sendMail(html, text) { + mailer.sendMail({ + from: { + name: configItems.defaultFrom, + address: configItems.defaultAddress + }, + to: { + name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '), + address: email + }, + subject: util.format(subject, list.name), + encryptionKeys + }, { + html, + text, + data + }, err => { + if (err) { + log.error('Subscription', err); + } + }); + } + + let text = { + template: 'subscription/mail-' + template + '-text.hbs' + }; + + let html = { + template: 'subscription/mail-' + template + '-html.mjml.hbs', + layout: 'subscription/layout.mjml.hbs', + type: 'mjml' + }; + + helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => { + if (err) { + return sendMail(html, text); + } + + sendMail(tmpl.html, tmpl.text); + }); + + return callback(null, cid); + }); + }); + }); + }); +}; + + +/* +FIXME +function getUnsubscriptionMode = (listId, start, limit, callback) => { + listId = Number(listId) || 0; + if (!listId) { + return callback(new Error('Missing List ID')); + } + + tableHelpers.list('subscription__' + listId, ['*'], 'email', null, start, limit, (err, rows, total) => { + if (!err) { + rows = rows.map(row => tools.convertKeys(row)); + } + return callback(err, rows, total); + }); +}; + +*/ \ No newline at end of file diff --git a/lib/tools.js b/lib/tools.js index 2c508917..49d17e44 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]; diff --git a/routes/api.js b/routes/api.js index ce71a7c1..671bac8f 100644 --- a/routes/api.js +++ b/routes/api.js @@ -93,6 +93,8 @@ router.post('/subscribe/:listId', (req, res) => { subscription.tz = (input.TIMEZONE || '').toString().trim(); } + subscription._action = 'subscribe'; + fields.list(list.id, (err, fieldList) => { if (err && !fieldList) { fieldList = []; diff --git a/routes/forms.js b/routes/forms.js index 17c5e896..6c3935ac 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,18 +227,43 @@ router.get('/:list/edit/:form', passport.csrfProtection, (req, res) => { type: 'mjml', help: helpMjmlGeneral }, { - name: 'web_unsubscribe_notice', - label: _('Web - Unsubscribe Notice'), + name: 'web_confirm_unsubscription_notice', + label: _('Web - Confirm Unsubscription Notice'), type: 'mjml', help: helpMjmlGeneral }, { - name: 'mail_unsubscribe_confirmed_html', - label: _('Mail - Unsubscribe Confirmed (MJML)'), + name: 'mail_confirm_unsubscription_html', + label: _('Mail - Confirm Unsubscription (MJML)'), type: 'mjml', help: helpMjmlGeneral }, { - name: 'mail_unsubscribe_confirmed_text', - label: _('Mail - Unsubscribe Confirmed (Text)'), + name: 'mail_confirm_unsubscription_text', + label: _('Mail - Confirm Unsubscription (Text)'), + type: 'text', + help: helpEmailText + }, { + name: 'mail_confirm_address_change_html', + label: _('Mail - Confirm Address Change (MJML)'), + type: 'mjml', + help: helpMjmlGeneral + }, { + name: 'mail_confirm_address_change_text', + label: _('Mail - Confirm Address Change (Text)'), + type: 'text', + help: helpEmailText + }, { + name: 'web_unsubscribed_notice', + label: _('Web - Unsubscribed Notice'), + type: 'mjml', + help: helpMjmlGeneral + }, { + name: 'mail_unsubscription_confirmed_html', + label: _('Mail - Unsubscription Confirmed (MJML)'), + type: 'mjml', + help: helpMjmlGeneral + }, { + name: 'mail_unsubscription_confirmed_text', + label: _('Mail - Unsubscription Confirmed (Text)'), type: 'text', help: helpEmailText }] diff --git a/routes/lists.js b/routes/lists.js index 64c1352f..4618c683 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); }); @@ -771,4 +775,28 @@ router.post('/quicklist/ajax', (req, res) => { }); }); +function getUnsubscriptionModeOptions(unsubscriptionMode) { + const options = []; + + options[lists.UnsubscriptionMode.ONE_STEP] = { + value: lists.UnsubscriptionMode.ONE_STEP, + selected: unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP, + label: _('One-step (i.e. no email with confirmation link)') + }; + + options[lists.UnsubscriptionMode.TWO_STEP] = { + value: lists.UnsubscriptionMode.TWO_STEP, + selected: unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP, + label: _('Two-step (i.e. an email with confirmation link will be sent)') + }; + + options[lists.UnsubscriptionMode.MANUAL] = { + value: lists.UnsubscriptionMode.MANUAL, + selected: unsubscriptionMode === lists.UnsubscriptionMode.MANUAL, + label: _('Manual (i.e. unsubscription has to be performed by the list administrator)') + }; + + return options; +} + module.exports = router; diff --git a/routes/subscription.js b/routes/subscription.js index 0e91eb98..e8f2c5f4 100644 --- a/routes/subscription.js +++ b/routes/subscription.js @@ -44,8 +44,8 @@ let corsOrCsrfProtection = (req, res, next) => { } }; -router.get('/subscribe/:cid', (req, res, next) => { - subscriptions.subscribe(req.params.cid, req.ip, (err, subscription) => { +router.get('/confirm/:cid', (req, res, next) => { + subscriptions.processConfirmation(req.params.cid, req.ip, (err, subscription, action) => { if (!err && !subscription) { err = new Error(_('Selected subscription not found')); err.status = 404; @@ -70,40 +70,9 @@ router.get('/subscribe/:cid', (req, res, next) => { return next(err); } - let data = { - title: list.name, - homepage: configItems.defaultHomepage || configItems.serviceUrl, - preferences: '/subscription/' + list.cid + '/manage/' + subscription.cid, - hasPubkey: !!configItems.pgpPrivateKey, - defaultAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - template: { - template: 'subscription/web-subscribed.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - } - }; + // FIXME - split decision based on action - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribed', data, (err, data) => { - if (err) { - return next(err); - } - - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { - if (err) { - return next(err); - } - - helpers.captureFlashMessages(req, res, (err, flash) => { - if (err) { - return next(err); - } - - data.isWeb = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); - }); - }); - }); + res.redirect('/subscription/' + list.cid + '/subscribed-notice'); if (configItems.disableConfirmations) { return; @@ -318,164 +287,6 @@ router.get('/:cid/widget', cors(corsOptions), (req, res, next) => { }); }); -router.get('/:cid/confirm-notice', (req, res, next) => { - lists.getByCid(req.params.cid, (err, list) => { - if (!err && !list) { - err = new Error(_('Selected list not found')); - err.status = 404; - } - - if (err) { - return next(err); - } - - settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { - if (err) { - return next(err); - } - - let data = { - title: list.name, - homepage: configItems.defaultHomepage || configItems.serviceUrl, - defaultAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - template: { - template: 'subscription/web-confirm-notice.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - } - }; - - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-confirm-notice', data, (err, data) => { - if (err) { - return next(err); - } - - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { - if (err) { - return next(err); - } - - helpers.captureFlashMessages(req, res, (err, flash) => { - if (err) { - return next(err); - } - - data.isWeb = true; - data.isConfirmNotice = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); - }); - }); - }); - }); - }); -}); - -router.get('/:cid/updated-notice', (req, res, next) => { - lists.getByCid(req.params.cid, (err, list) => { - if (!err && !list) { - err = new Error(_('Selected list not found')); - err.status = 404; - } - - if (err) { - return next(err); - } - - settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { - if (err) { - return next(err); - } - - let data = { - title: list.name, - homepage: configItems.defaultHomepage || configItems.serviceUrl, - defaultAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - template: { - template: 'subscription/web-updated-notice.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - } - }; - - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-updated-notice', data, (err, data) => { - if (err) { - return next(err); - } - - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { - if (err) { - return next(err); - } - - helpers.captureFlashMessages(req, res, (err, flash) => { - if (err) { - return next(err); - } - - data.isWeb = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); - }); - }); - }); - }); - }); -}); - -router.get('/:cid/unsubscribe-notice', (req, res, next) => { - lists.getByCid(req.params.cid, (err, list) => { - if (!err && !list) { - err = new Error(_('Selected list not found')); - err.status = 404; - } - - if (err) { - return next(err); - } - - settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { - if (err) { - return next(err); - } - - let data = { - title: list.name, - layout: 'subscription/layout', - homepage: configItems.defaultHomepage || configItems.serviceUrl, - defaultAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - template: { - template: 'subscription/web-unsubscribe-notice.mjml.hbs', - layout: 'subscription/layout.mjml.hbs' - } - }; - - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-unsubscribe-notice', data, (err, data) => { - if (err) { - return next(err); - } - - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { - if (err) { - return next(err); - } - - helpers.captureFlashMessages(req, res, (err, flash) => { - if (err) { - return next(err); - } - - data.isWeb = true; - data.flashMessages = flash; - res.send(htmlRenderer(data)); - }); - }); - }); - }); - }); -}); - router.options('/:cid/subscribe', cors(corsOptions)); router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, res, next) => { @@ -496,62 +307,73 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); } - // Check if the subscriber seems legit. This is a really simple check, the only requirement is that - // the subsciber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this - // simple check should be replaced with an actual captcha - let subTime = Number(req.body.sub) || 0; - // allow clock skew 24h in the past and 24h to the future - let subTimeTest = !!(subTime > Date.now() - 24 * 3600 * 1000 && subTime < Date.now() + 24 * 3600 * 1000); - let addressTest = !req.body.address; - let testsPass = subTimeTest && addressTest; - - lists.getByCid(req.params.cid, (err, list) => { - if (!err) { - if (!list) { - err = new Error(_('Selected list not found')); - err.status = 404; - } else if (!list.publicSubscribe) { - err = new Error(_('The list does not allow public subscriptions.')); - err.status = 403; - } - } - + tools.validateEmail(email, false, err => { if (err) { - return req.xhr ? sendJsonError(err) : next(err); + if (req.xhr) { + return sendJsonError(err.message, 400); + } + req.flash('danger', err.message); + return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); } - let data = {}; - Object.keys(req.body).forEach(key => { - if (key !== 'email' && key.charAt(0) !== '_') { - data[key] = (req.body[key] || '').toString().trim(); - } - }); + // Check if the subscriber seems legit. This is a really simple check, the only requirement is that + // the subsciber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this + // simple check should be replaced with an actual captcha + let subTime = Number(req.body.sub) || 0; + // allow clock skew 24h in the past and 24h to the future + let subTimeTest = !!(subTime > Date.now() - 24 * 3600 * 1000 && subTime < Date.now() + 24 * 3600 * 1000); + let addressTest = !req.body.address; + let testsPass = subTimeTest && addressTest; - data = tools.convertKeys(data); - - data._address = req.body.address; - data._sub = req.body.sub; - data._skip = !testsPass; - - subscriptions.addConfirmation(list, email, req.ip, data, (err, confirmCid) => { - if (!err && !confirmCid) { - err = new Error(_('Could not store confirmation data')); + lists.getByCid(req.params.cid, (err, list) => { + if (!err) { + if (!list) { + err = new Error(_('Selected list not found')); + err.status = 404; + } else if (!list.publicSubscribe) { + err = new Error(_('The list does not allow public subscriptions.')); + err.status = 403; + } } if (err) { - if (req.xhr) { - return sendJsonError(err); - } - req.flash('danger', err.message || err); - return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); + return req.xhr ? sendJsonError(err) : next(err); } - if (req.xhr) { - return res.status(200).json({ - msg: _('Please Confirm Subscription') - }); - } - res.redirect('/subscription/' + req.params.cid + '/confirm-notice'); + let data = {}; + Object.keys(req.body).forEach(key => { + if (key !== 'email' && key.charAt(0) !== '_') { + data[key] = (req.body[key] || '').toString().trim(); + } + }); + + data = tools.convertKeys(data); + + data._address = req.body.address; + data._sub = req.body.sub; + data._skip = !testsPass; + data._action = 'subscribe'; + + subscriptions.addConfirmation(list, email, req.ip, data, (err, confirmCid) => { + if (!err && !confirmCid) { + err = new Error(_('Could not store confirmation data')); + } + + if (err) { + if (req.xhr) { + return sendJsonError(err); + } + req.flash('danger', err.message || err); + return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); + } + + if (req.xhr) { + return res.status(200).json({ + msg: _('Please Confirm Subscription') + }); + } + res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice'); + }); }); }); }); @@ -732,7 +554,7 @@ router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/manage-address/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body)); } - req.flash('info', _('Email address updated, check your mailbox for verification instructions')); + req.flash('info', _('An email with further instructions has been sent to the provided address')); res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid); }); }); @@ -822,7 +644,7 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, ( log.error('Subscription', err); return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/unsubscribe/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body)); } - res.redirect('/subscription/' + req.params.lcid + '/unsubscribe-notice'); + res.redirect('/subscription/' + req.params.lcid + '/unsubscribed-notice'); fields.list(list.id, (err, fieldList) => { if (err) { @@ -874,11 +696,11 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, ( }; let text = { - template: 'subscription/mail-unsubscribe-confirmed-text.hbs' + template: 'subscription/mail-unsubscription-confirmed-text.hbs' }; let html = { - template: 'subscription/mail-unsubscribe-confirmed-html.mjml.hbs', + template: 'subscription/mail-unsubscription-confirmed-html.mjml.hbs', layout: 'subscription/layout.mjml.hbs', type: 'mjml' }; @@ -896,6 +718,26 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, ( }); }); +router.get('/:cid/confirm-subscription-notice', (req, res, next) => { + notice('confirm-subscription', req, res, next); +}); + +router.get('/:cid/confirm-unsubscription-notice', (req, res, next) => { + notice('confirm-unsubscription', req, res, next); +}); + +router.get('/:cid/subscribed-notice', (req, res, next) => { + notice('subscribed', req, res, next); +}); + +router.get('/:cid/updated-notice', (req, res, next) => { + notice('updated', req, res, next); +}); + +router.get('/:cid/unsubscribed-notice', (req, res, next) => { + notice('unsubscribed', req, res, next); +}); + router.post('/publickey', passport.parseForm, (req, res, next) => { settings.list(['pgpPassphrase', 'pgpPrivateKey'], (err, configItems) => { if (err) { @@ -934,4 +776,59 @@ router.post('/publickey', passport.parseForm, (req, res, next) => { }); }); + +function notice(type, req, res, next) { + lists.getByCid(req.params.cid, (err, list) => { + if (!err && !list) { + err = new Error(_('Selected list not found')); + err.status = 404; + } + + if (err) { + return next(err); + } + + settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { + if (err) { + return next(err); + } + + let data = { + title: list.name, + homepage: configItems.defaultHomepage || configItems.serviceUrl, + defaultAddress: configItems.defaultAddress, + defaultPostaddress: configItems.defaultPostaddress, + template: { + template: 'subscription/web-' + type + '-notice.mjml.hbs', + layout: 'subscription/layout.mjml.hbs' + } + }; + + helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-' + type + '-notice', data, (err, data) => { + if (err) { + return next(err); + } + + helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { + if (err) { + return next(err); + } + + helpers.captureFlashMessages(req, res, (err, flash) => { + if (err) { + return next(err); + } + + data.isWeb = true; + data.isConfirmNotice = true; + data.flashMessages = flash; + res.send(htmlRenderer(data)); + }); + }); + }); + }); + }); +} + + module.exports = router; diff --git a/setup/sql/upgrade-00028.sql b/setup/sql/upgrade-00028.sql new file mode 100644 index 00000000..8ea8bdbc --- /dev/null +++ b/setup/sql/upgrade-00028.sql @@ -0,0 +1,24 @@ +# Header section +# Define incrementing schema version number +SET @schema_version = '28'; + +# Add unsubscription mode field to lists +ALTER TABLE `lists` ADD COLUMN `unsubscription_mode` int(11) unsigned DEFAULT 0 NOT NULL AFTER `public_subscribe`; + +# Change the name of the column to better reflect that confirmations are also used for unsubscription and email address update +ALTER TABLE `confirmations` CHANGE `opt_in_ip` `ip` varchar(100) DEFAULT NULL; + +# Rename affected forms in custom_forms_data +update custom_forms_data set data_key="mail_confirm_subscription_html" where data_key="mail_confirm_html"; +update custom_forms_data set data_key="mail_confirm_subscription_text" where data_key="mail_confirm_text"; +update custom_forms_data set data_key="mail_unsubscription_confirmed_html" where data_key="mail_unsubscribe_confirmed_html"; +update custom_forms_data set data_key="mail_unsubscription_confirmed_text" where data_key="mail_unsubscribe_confirmed_text"; +update custom_forms_data set data_key="web_confirm_subscription_notice" where data_key="web_confirm_notice"; +update custom_forms_data set data_key="web_subscribed_notice" where data_key="web_subscribed"; +update custom_forms_data set data_key="web_unsubscribed_notice" where data_key="web_unsubscribe_notice"; + + +# Footer section +LOCK TABLES `settings` WRITE; +INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version; +UNLOCK TABLES; 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}}Select how an unsuscription request by subscriber is handled.{{/translate}}
diff --git a/views/lists/edit.hbs b/views/lists/edit.hbs index 5d206b50..cbe0b6cc 100644 --- a/views/lists/edit.hbs +++ b/views/lists/edit.hbs @@ -56,11 +56,27 @@
-
-
- +
+ +
+
+ +
+
+
+ + +
+ +
+ + {{#translate}}Select how an unsuscription request by subscriber is handled.{{/translate}}
diff --git a/views/lists/forms/edit.hbs b/views/lists/forms/edit.hbs index 0ee5acdf..5b91dcfb 100644 --- a/views/lists/forms/edit.hbs +++ b/views/lists/forms/edit.hbs @@ -46,11 +46,15 @@

{{#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}} {{#if testUsers}} | {{#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 @@

- +
diff --git a/views/subscription/mail-already-subscribed-html.mjml.hbs b/views/subscription/mail-already-subscribed-html.mjml.hbs new file mode 100644 index 00000000..d4a9b20e --- /dev/null +++ b/views/subscription/mail-already-subscribed-html.mjml.hbs @@ -0,0 +1,24 @@ + + + + {{#translate}}Email address already subscribed{{/translate}} + + + {{#translate}}We have received a subscription request. Your email address is however already registered.{{/translate}}. + + + {{#translate}}If you received this email by mistake, simply delete it. Your existing subscription won't be affected.{{/translate}} + + + {{#translate}}If you want to modify your subscription then you can {{/translate}} + {{#translate}}manage your preferences{{/translate}} {{#translate}}or{{/translate}} {{#translate}}unsubscribe here{{/translate}}. + + + {{#translate}}Return to our website{{/translate}} + + + {{#translate}}For questions about this list, please contact:{{/translate}} +
{{contactAddress}} +
+
+
diff --git a/views/subscription/mail-already-subscribed-text.hbs b/views/subscription/mail-already-subscribed-text.hbs new file mode 100644 index 00000000..fe982896 --- /dev/null +++ b/views/subscription/mail-already-subscribed-text.hbs @@ -0,0 +1,18 @@ +{{{title}}} +{{#translate}}Email address already subscribed{{/translate}} +================================ + +{{#translate}}We have received a subscription request. Your email address is however already registered.{{/translate}} + +{{#translate}}If you received this email by mistake, simply delete it. Your existing subscription won't be affected.{{/translate}} + +{{#translate}}If you want to modify your subscription then you can:{{/translate}} + +{{#translate}}manage your preferences{{/translate}}: {{preferencesUrl}} + +- {{#translate}}or{{/translate}} - + +{{#translate}}unsubscribe here{{/translate}}: {{unsubscribeUrl}} + +{{#translate}}For questions about this list, please contact:{{/translate}} +{{{contactAddress}}} diff --git a/views/subscription/mail-confirm-address-change-html.mjml.hbs b/views/subscription/mail-confirm-address-change-html.mjml.hbs new file mode 100644 index 00000000..a144fe33 --- /dev/null +++ b/views/subscription/mail-confirm-address-change-html.mjml.hbs @@ -0,0 +1,17 @@ + + + + {{#translate}}Please Confirm Subscription Address Change{{/translate}} + + + {{#translate}}Yes, subscribe this email address to the list{{/translate}} + + + {{#translate}}If you received this email by mistake, simply delete it. You won't be subscribed if you don't click the confirmation link above.{{/translate}} + + + {{#translate}}For questions about this list, please contact:{{/translate}} +
{{contactAddress}} +
+
+
diff --git a/views/subscription/mail-confirm-address-change-text.hbs b/views/subscription/mail-confirm-address-change-text.hbs new file mode 100644 index 00000000..baca9209 --- /dev/null +++ b/views/subscription/mail-confirm-address-change-text.hbs @@ -0,0 +1,10 @@ +{{{title}}} +{{#translate}}Please Confirm Subscription Address Change{{/translate}} +========================================== + +{{#translate}}Yes, subscribe this email address to the list{{/translate}}: {{{confirmUrl}}} + +{{#translate}}If you received this email by mistake, simply delete it. You won't be subscribed unless you click the confirmation link above.{{/translate}} + +{{#translate}}For questions about this list, please contact:{{/translate}} +{{{contactAddress}}} diff --git a/views/subscription/mail-confirm-html.mjml.hbs b/views/subscription/mail-confirm-subscription-html.mjml.hbs similarity index 100% rename from views/subscription/mail-confirm-html.mjml.hbs rename to views/subscription/mail-confirm-subscription-html.mjml.hbs diff --git a/views/subscription/mail-confirm-text.hbs b/views/subscription/mail-confirm-subscription-text.hbs similarity index 100% rename from views/subscription/mail-confirm-text.hbs rename to views/subscription/mail-confirm-subscription-text.hbs diff --git a/views/subscription/mail-confirm-unsubscription-html.mjml.hbs b/views/subscription/mail-confirm-unsubscription-html.mjml.hbs new file mode 100644 index 00000000..89f6bbce --- /dev/null +++ b/views/subscription/mail-confirm-unsubscription-html.mjml.hbs @@ -0,0 +1,17 @@ + + + + {{#translate}}Please Confirm Unsubscription{{/translate}} + + + {{#translate}}Yes, unsubscribe me from this list{{/translate}} + + + {{#translate}}If you received this email by mistake, simply delete it. You won't be unsubscribed if you don't click the confirmation link above.{{/translate}} + + + {{#translate}}For questions about this list, please contact:{{/translate}} +
{{contactAddress}} +
+
+
diff --git a/views/subscription/mail-confirm-unsubscription-text.hbs b/views/subscription/mail-confirm-unsubscription-text.hbs new file mode 100644 index 00000000..2dd2dd4f --- /dev/null +++ b/views/subscription/mail-confirm-unsubscription-text.hbs @@ -0,0 +1,10 @@ +{{{title}}} +{{#translate}}Please Confirm Subscription{{/translate}} +=========================== + +{{#translate}}Yes, unsubscribe me from this list{{/translate}}: {{{confirmUrl}}} + +{{#translate}}If you received this email by mistake, simply delete it. You won't be unsubscribed unless you click the confirmation link above.{{/translate}} + +{{#translate}}For questions about this list, please contact:{{/translate}} +{{{contactAddress}}} diff --git a/views/subscription/mail-unsubscribe-confirmed-html.mjml.hbs b/views/subscription/mail-unsubscription-confirmed-html.mjml.hbs similarity index 100% rename from views/subscription/mail-unsubscribe-confirmed-html.mjml.hbs rename to views/subscription/mail-unsubscription-confirmed-html.mjml.hbs diff --git a/views/subscription/mail-unsubscribe-confirmed-text.hbs b/views/subscription/mail-unsubscription-confirmed-text.hbs similarity index 100% rename from views/subscription/mail-unsubscribe-confirmed-text.hbs rename to views/subscription/mail-unsubscription-confirmed-text.hbs diff --git a/views/subscription/web-confirm-notice.mjml.hbs b/views/subscription/web-confirm-subscription-notice.mjml.hbs similarity index 100% rename from views/subscription/web-confirm-notice.mjml.hbs rename to views/subscription/web-confirm-subscription-notice.mjml.hbs diff --git a/views/subscription/web-confirm-unsubscription-notice.mjml.hbs b/views/subscription/web-confirm-unsubscription-notice.mjml.hbs new file mode 100644 index 00000000..5fc2537e --- /dev/null +++ b/views/subscription/web-confirm-unsubscription-notice.mjml.hbs @@ -0,0 +1,13 @@ + + + + {{#translate}}Almost Finished{{/translate}} + + + {{#translate}}We need to confirm your email address. To complete the unsubscription process, please click the link in the email we just sent you.{{/translate}} + + + {{#translate}}Return to our website{{/translate}} + + + diff --git a/views/subscription/web-subscribed.mjml.hbs b/views/subscription/web-subscribed-notice.mjml.hbs similarity index 100% rename from views/subscription/web-subscribed.mjml.hbs rename to views/subscription/web-subscribed-notice.mjml.hbs diff --git a/views/subscription/web-unsubscribe-notice.mjml.hbs b/views/subscription/web-unsubscribed-notice.mjml.hbs similarity index 100% rename from views/subscription/web-unsubscribe-notice.mjml.hbs rename to views/subscription/web-unsubscribed-notice.mjml.hbs From 32e2e61789128cecccafb84006dd794e6de05fca Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sun, 30 Apr 2017 13:01:22 -0400 Subject: [PATCH 02/11] Unsubscription is identified by subscriber cid. This effectivelly allows only the recipient of the email to unsubscribe. This addresses issue #221. I also scraped the "auto" parameter which automatically submits the unsubscription form when the link is clicked in a campaign email. Instead, I introduced the unsubscription options ONE_STEP, ONE_STEP_WITH_FORM, TWO_STEP, TWO_STEP_WITH_FORM. The options without "_WITH_FORM" shall behave like when called with "auto". This functionality is to come. Currently it behaves as ONE_STEP_WITH_FORM. --- lib/models/lists.js | 8 +- lib/models/subscriptions.js | 227 ++++++------------ lib/tools.js | 2 +- routes/lists.js | 12 + routes/subscription.js | 168 +++---------- services/sender.js | 2 +- .../subscription-unsubscribe-form.hbs | 11 +- views/subscription/web-unsubscribe.mjml.hbs | 3 - 8 files changed, 126 insertions(+), 307 deletions(-) diff --git a/lib/models/lists.js b/lib/models/lists.js index 7ee7dcc6..839fefe1 100644 --- a/lib/models/lists.js +++ b/lib/models/lists.js @@ -9,9 +9,11 @@ let tableHelpers = require('../table-helpers'); const UnsubscriptionMode = { ONE_STEP: 0, - TWO_STEP: 1, - MANUAL: 2, - MAX: 3 + ONE_STEP_WITH_FORM: 1, + TWO_STEP: 2, + TWO_STEP_WITH_FORM: 3, + MANUAL: 4, + MAX: 5 }; module.exports.UnsubscriptionMode = UnsubscriptionMode; diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index 57169257..24158ffd 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -107,84 +107,20 @@ module.exports.addConfirmation = (list, email, ip, data, callback) => { return callback(null, false); } - fields.list(list.id, (err, fieldList) => { - if (err) { - return callback(err); - } + if (data._skip) { + log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data)); + return callback(null, cid); + } - 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; - } - - // FIXME - move to router - const mailOpts = { - subject: _('%s: Please Confirm Subscription'), - confirmUrlRoute: '/subscription/confirm/', - templateType: 'subscription' - }; - - let sendMail = (html, text) => { - mailer.sendMail({ - from: { - name: configItems.defaultFrom, - address: configItems.defaultAddress - }, - to: { - name: [].concat(data.firstName || []).concat(data.lastName || []).join(' '), - address: email - }, - subject: util.format(mailOpts.subject, list.name), - encryptionKeys - }, { - html, - text, - data: { - title: list.name, - contactAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - confirmUrl: urllib.resolve(configItems.serviceUrl, mailOpts.confirmUrlRoute + cid) - } - }, err => { - if (err) { - log.error('Subscription', err); - } - }); - }; - - let text = { - template: 'subscription/mail-confirm-' + mailOpts.templateType + '-text.hbs' - }; - - let html = { - template: 'subscription/mail-confirm-' + mailOpts.templateType + '-html.mjml.hbs', - layout: 'subscription/layout.mjml.hbs', - type: 'mjml' - }; - - helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => { - if (err) { - return sendMail(html, text); - } - - sendMail(tmpl.html, tmpl.text); - }); - }); - return callback(null, cid); - }); + // FIXME - customize from the router + const mailOpts = { + ignoreDisableConfirmations: true + }; + const relativeUrls = { + confirmUrl: '/subscription/confirm/' + cid + }; + module.exports.sendMail(list, email, 'confirm-subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, data, (err) => { + return callback(err, cid); }); }); }); @@ -395,8 +331,6 @@ module.exports.insert = (listId, meta, subscription, callback) => { queryArgs = values.concat(existing.id); query = 'UPDATE `subscription__' + listId + '` SET ' + keys.map(key => '`' + key + '`=?') + ' WHERE id=? LIMIT 1'; } - console.log(query); - console.log(queryArgs); connection.query(query, queryArgs, (err, result) => { if (err) { @@ -647,9 +581,8 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => { }); }; -module.exports.unsubscribe = (listId, email, campaignId, callback) => { +module.exports.unsubscribe = (listId, subscriberCid, campaignId, callback) => { listId = Number(listId) || 0; - email = (email || '').toString().trim(); campaignId = (campaignId || '').toString().trim() || false; @@ -657,8 +590,8 @@ module.exports.unsubscribe = (listId, email, campaignId, callback) => { return callback(new Error(_('Missing List ID'))); } - if (!email) { - return callback(new Error(_('Missing email address'))); + if (!subscriberCid) { + return callback(new Error(_('Missing subscriber cid'))); } db.getConnection((err, connection) => { @@ -666,7 +599,7 @@ module.exports.unsubscribe = (listId, email, campaignId, callback) => { return callback(err); } - connection.query('SELECT * FROM `subscription__' + listId + '` WHERE `email`=?', [email], (err, rows) => { + connection.query('SELECT * FROM `subscription__' + listId + '` WHERE `cid`=?', [subscriberCid], (err, rows) => { connection.release(); if (err) { return callback(err); @@ -1166,92 +1099,86 @@ module.exports.updateAddress = (list, cid, updates, ip, callback) => { }; -module.exports.sendMail = (listId, email, template, subject, mailOpts, subscription, callback) => { +module.exports.sendMail = (list, email, template, subject, relativeUrls, mailOpts, subscription, callback) => { db.getConnection((err, connection) => { if (err) { return callback(err); } - lists.get(listId, (err, list) => { - if (!err && !list) { - err = new Error(_('Selected list not found')); - err.status = 404; - } - + fields.list(list.id, (err, fieldList) => { if (err) { - return next(err); + return callback(err); } - fields.list(list.id, (err, fieldList) => { + 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); } - let encryptionKeys = []; - fields.getRow(fieldList, subscription).forEach(field => { - if (field.type === 'gpg' && field.value) { - encryptionKeys.push(field.value.trim()); - } - }); + if (!mailOpts.ignoreDisableConfirmations && configItems.disableConfirmations) { + return; + } - settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl'], (err, configItems) => { - if (err) { - return callback(err); - } + const data = { + title: list.name, + homepage: configItems.defaultHomepage || configItems.serviceUrl, + contactAddress: configItems.defaultAddress, + defaultPostaddress: configItems.defaultPostaddress, + }; - const data = { - title: list.name, - contactAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - }; + for (let relativeUrlKey in relativeUrls) { + data[relativeUrlKey] = urllib.resolve(configItems.serviceUrl, relativeUrls[relativeUrlKey]); + } - if (mailOpts.confirmUrlRoute) { - data.confirmUrl = urllib.resolve(configItems.serviceUrl, mailOpts.confirmUrlRoute + cid) - } - - function sendMail(html, text) { - mailer.sendMail({ - from: { - name: configItems.defaultFrom, - address: configItems.defaultAddress - }, - to: { - name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '), - address: email - }, - subject: util.format(subject, list.name), - encryptionKeys - }, { - html, - text, - data - }, err => { - if (err) { - log.error('Subscription', err); - } - }); - } - - let text = { - template: 'subscription/mail-' + template + '-text.hbs' - }; - - let html = { - template: 'subscription/mail-' + template + '-html.mjml.hbs', - layout: 'subscription/layout.mjml.hbs', - type: 'mjml' - }; - - helpers.injectCustomFormTemplates(list.defaultForm, { text, html }, (err, tmpl) => { + 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) { - return sendMail(html, text); + log.error('Subscription', err); } - - sendMail(tmpl.html, tmpl.text); }); + } - return callback(null, cid); + 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 49d17e44..901ad0df 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -181,7 +181,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/routes/lists.js b/routes/lists.js index 4618c683..402c9095 100644 --- a/routes/lists.js +++ b/routes/lists.js @@ -784,12 +784,24 @@ function getUnsubscriptionModeOptions(unsubscriptionMode) { 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, diff --git a/routes/subscription.js b/routes/subscription.js index e8f2c5f4..193ff63f 100644 --- a/routes/subscription.js +++ b/routes/subscription.js @@ -65,80 +65,23 @@ router.get('/confirm/:cid', (req, res, next) => { return next(err); } - settings.list(['defaultHomepage', 'serviceUrl', 'pgpPrivateKey', 'defaultAddress', 'defaultPostaddress', 'defaultFrom', 'disableConfirmations'], (err, configItems) => { - if (err) { - return next(err); - } + // FIXME - differentiate email based on action - // FIXME - split decision based on action + const relativeUrls = { + preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid, + unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + }; + + subscriptions.sendMail(list, subscription.email, 'subscription-confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, subscription, (err) => { + 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)); + } res.redirect('/subscription/' + list.cid + '/subscribed-notice'); - - if (configItems.disableConfirmations) { - return; - } - - fields.list(list.id, (err, fieldList) => { - if (err) { - return log.error('Fields', err); - } - - let encryptionKeys = []; - fields.getRow(fieldList, subscription).forEach(field => { - if (field.type === 'gpg' && field.value) { - encryptionKeys.push(field.value.trim()); - } - }); - - let sendMail = (html, text) => { - mailer.sendMail({ - from: { - name: configItems.defaultFrom, - address: configItems.defaultAddress - }, - to: { - name: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '), - address: subscription.email - }, - subject: util.format(_('%s: Subscription Confirmed'), list.name), - encryptionKeys - }, { - html, - text, - data: { - title: list.name, - homepage: configItems.defaultHomepage || configItems.serviceUrl, - contactAddress: configItems.defaultAddress, - defaultPostaddress: configItems.defaultPostaddress, - preferencesUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid), - unsubscribeUrl: urllib.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid) - } - }, err => { - if (err) { - log.error('Subscription', err); - } - }); - }; - - let text = { - template: 'subscription/mail-subscription-confirmed-text.hbs' - }; - - let html = { - template: 'subscription/mail-subscription-confirmed-html.mjml.hbs', - layout: 'subscription/layout.mjml.hbs', - type: 'mjml' - }; - - helpers.injectCustomFormTemplates(req.query.fid || list.defaultForm, { text, html }, (err, tmpl) => { - if (err) { - return sendMail(html, text); - } - - sendMail(tmpl.html, tmpl.text); - }); - }); }); + }); }); }); @@ -159,6 +102,8 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => { return next(err); } + // FIXME: process subscriber cid param for resubscription requests + let data = tools.convertKeys(req.query, { skip: ['layout'] }); @@ -561,6 +506,8 @@ router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection }); router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) => { + // FIXME: handle different subscription options. The one below is currently "One-step with unsubscribe form" + lists.getByCid(req.params.lcid, (err, list) => { if (!err && !list) { err = new Error(_('Selected list not found')); @@ -587,9 +534,9 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) } subscription.lcid = req.params.lcid; + subscription.ucid = req.params.ucid; subscription.title = list.name; subscription.csrfToken = req.csrfToken(); - subscription.autosubmit = !!req.query.auto; subscription.campaign = req.query.c; subscription.defaultAddress = configItems.defaultAddress; subscription.defaultPostaddress = configItems.defaultPostaddress; @@ -636,83 +583,24 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, ( return next(err); } - let email = req.body.email; - - subscriptions.unsubscribe(list.id, email, req.body.campaign, (err, subscription) => { + subscriptions.unsubscribe(list.id, req.body.ucid, req.body.campaign, (err, subscription) => { if (err) { req.flash('danger', err.message || err); log.error('Subscription', err); - return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/unsubscribe/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body)); + return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/unsubscribe/' + encodeURIComponent(req.body.ucid) + '?' + tools.queryParams(req.body)); } - res.redirect('/subscription/' + req.params.lcid + '/unsubscribed-notice'); - fields.list(list.id, (err, fieldList) => { + const relativeUrls = { + subscribeUrl: '/subscription/' + list.cid + '?cid=' + subscription.cid + }; + subscriptions.sendMail(list, subscription.email, 'unsubscription-confirmed', _('%s: Unsubscribe Confirmed'), relativeUrls, {}, subscription, (err) => { if (err) { - return log.error('Fields', err); + req.flash('danger', err.message || err); + log.error('Subscription', err); + return res.redirect('/subscription/' + encodeURIComponent(list.cid) + '/unsubscribe/' + encodeURIComponent(subscription.cid) + '?' + tools.queryParams(req.body)); } - 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-unsubscription-confirmed-text.hbs' - }; - - let html = { - template: 'subscription/mail-unsubscription-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); - }); - }); + res.redirect('/subscription/' + req.params.lcid + '/unsubscribed-notice'); }); }); }); diff --git a/services/sender.js b/services/sender.js index 0610a7bb..ea3e05de 100644 --- a/services/sender.js +++ b/services/sender.js @@ -418,7 +418,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/views/subscription/partials/subscription-unsubscribe-form.hbs b/views/subscription/partials/subscription-unsubscribe-form.hbs index 8a11ebe2..4f40dcdc 100644 --- a/views/subscription/partials/subscription-unsubscribe-form.hbs +++ b/views/subscription/partials/subscription-unsubscribe-form.hbs @@ -1,20 +1,13 @@ - +
- +
-{{#if email}} - {{#if autosubmit}} - - {{/if}} -{{/if}} diff --git a/views/subscription/web-unsubscribe.mjml.hbs b/views/subscription/web-unsubscribe.mjml.hbs index a34892d4..4ea3c4fa 100644 --- a/views/subscription/web-unsubscribe.mjml.hbs +++ b/views/subscription/web-unsubscribe.mjml.hbs @@ -3,9 +3,6 @@ {{#translate}}Unsubscribe{{/translate}} - - {{#translate}}Enter your email address to unsubscribe from:{{/translate}} {{title}} - {{> subscription_unsubscribe_form}} From bd4961366fa21b79b39cf3769579493c25836051 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Wed, 3 May 2017 15:46:49 -0400 Subject: [PATCH 03/11] More or less all the functionality for selectable unsubscription process. Not tested yet! Sending emails moved completely to controller. It felt strange to have some emails sent from the controller and some of them from the model. Confirmations refactored to an independent model that can be potentially used also for other actions that need an email confirmation. --- lib/helpers.js | 10 +- lib/models/campaigns.js | 2 +- lib/models/confirmations.js | 90 ++++ lib/models/forms.js | 3 +- lib/models/reports.js | 2 +- lib/models/subscriptions.js | 498 ++++-------------- lib/subscription-mail-helpers.js | 166 ++++++ routes/forms.js | 5 + routes/subscription.js | 358 +++++++++---- setup/sql/upgrade-00028.sql | 9 + .../mail-already-subscribed-html.mjml.hbs | 2 +- .../mail-already-subscribed-text.hbs | 2 +- .../web-manual-unsubscribe-notice.mjml.hbs | 13 + 13 files changed, 672 insertions(+), 488 deletions(-) create mode 100644 lib/models/confirmations.js create mode 100644 lib/subscription-mail-helpers.js create mode 100644 views/subscription/web-manual-unsubscribe-notice.mjml.hbs diff --git a/lib/helpers.js b/lib/helpers.js index 2cac3dbb..8324a811 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -21,7 +21,8 @@ module.exports = { injectCustomFormData, injectCustomFormTemplates, filterCustomFields, - getMjmlTemplate + getMjmlTemplate, + rollbackAndReleaseConnection }; function getDefaultMergeTags(callback) { @@ -258,3 +259,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..c25c57ee 100644 --- a/lib/models/campaigns.js +++ b/lib/models/campaigns.js @@ -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 === 2 ? 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..66025ed1 --- /dev/null +++ b/lib/models/confirmations.js @@ -0,0 +1,90 @@ +'use strict'; + +let db = require('../db'); +let shortid = require('shortid'); +let helpers = require('../helpers'); + +/* + 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, list, action, ip, JSON.stringify(data || {})], (err, result) => { + connection.release(); + if (err) { + return callback(err); + } + + if (!result || !result.affectedRows) { + return callback(null, false); + } + + 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 802e5706..9550077b 100644 --- a/lib/models/forms.js +++ b/lib/models/forms.js @@ -34,7 +34,8 @@ let allowedKeys = [ 'mail_confirm_address_change_text', 'web_unsubscribed_notice', 'mail_unsubscription_confirmed_html', - 'mail_unsubscription_confirmed_text' + 'mail_unsubscription_confirmed_text', + 'web_manual_unsubscribe_notice' ]; diff --git a/lib/models/reports.js b/lib/models/reports.js index 0c6a91f9..aac2d967 100644 --- a/lib/models/reports.js +++ b/lib/models/reports.js @@ -245,8 +245,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/subscriptions.js b/lib/models/subscriptions.js index 24158ffd..08f2cbac 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -5,12 +5,9 @@ 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'); @@ -88,150 +85,17 @@ module.exports.filter = (listId, request, columns, segmentId, callback) => { }; -module.exports.addConfirmation = (list, email, ip, data, callback) => { - let cid = shortid.generate(); - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - let query = 'INSERT INTO confirmations (cid, list, email, ip, data) VALUES (?,?,?,?,?)'; - connection.query(query, [cid, list.id, email, ip, JSON.stringify(data || {})], (err, result) => { - connection.release(); - if (err) { - return callback(err); - } - - if (!result || !result.affectedRows) { - return callback(null, false); - } - - if (data._skip) { - log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data)); - return callback(null, cid); - } - - // FIXME - customize from the router - const mailOpts = { - ignoreDisableConfirmations: true - }; - const relativeUrls = { - confirmUrl: '/subscription/confirm/' + cid - }; - module.exports.sendMail(list, email, 'confirm-subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, data, (err) => { - return callback(err, cid); - }); - }); - }); -}; - -module.exports.processConfirmation = (cid, ip, 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') { - if (!subscription.subscriber) { // Something went terribly wrong and we don't have data that we have originally provided - return callback(new Error(_('Subscriber info corrupted or missing'))); - } - - // update email address instead of adding new - db.getConnection((err, connection) => { - if (err) { - 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(); - return module.exports.getById(listId, subscription.subscriber, (err, subscriptionData) => { - return callback(err, subscriptionData, subscription._action); - }); - }); - }); - }); - return; - - } else if (subscription._action === 'unsubscribe') { - // TODO - return; - - } else if (subscription._action === 'subscribe') { - subscription.cid = cid; - subscription.list = listId; - subscription.email = email; - - let optInCountry = geoip.lookupCountry(ip) || null; - module.exports.insert(listId, { - email, - cid, - optInIp: ip, - optInCountry, - status: 1 - }, subscription, (err, result) => { - if (err) { - return callback(err); - } - - if (!result.entryId) { - return callback(new Error(_('Could not save subscription'))); - } - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - connection.query('DELETE FROM confirmations WHERE `cid`=? LIMIT 1', [cid], () => { - connection.release(); - // reload full data from db in case it was an update, not insert - return module.exports.getById(listId, result.entryId, (err, subscriptionData) => { - return callback(err, subscriptionData, subscription._action); - }); - }); - }); - }); - - } else { - return callback(new Error(util.format(_('Subscription request corrupted - action: %s'), subscription._action))); - } - - }); - }); -}; - -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) => { @@ -245,8 +109,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(); @@ -260,8 +124,7 @@ module.exports.insert = (listId, meta, subscription, callback) => { } }); - // FIXME - see issue #218 - fields.getValues(fields.getRow(fieldList, subscription, true, true, !!meta.partial), true).forEach(field => { + fields.getValues(fields.getRow(fieldList, subscriptionData, true, true, !!meta.partial), true).forEach(field => { keys.push(field.key); values.push(field.value); }); @@ -280,10 +143,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; @@ -297,6 +157,10 @@ module.exports.insert = (listId, meta, subscription, callback) => { let statusChange = !existing || existing.status !== meta.status; let statusDirection; + if (existing && !meta.partial) { + return helpers.rollbackAndReleaseConnection(connection, new Error(_('Email address already registered')), callback); + } + if (statusChange) { keys.push('status', 'status_change'); values.push(meta.status, new Date()); @@ -307,10 +171,7 @@ module.exports.insert = (listId, meta, subscription, callback) => { // 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, { @@ -334,10 +195,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; @@ -345,17 +203,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, { @@ -368,10 +220,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, { @@ -529,7 +378,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) => { @@ -581,45 +430,7 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => { }); }; -module.exports.unsubscribe = (listId, subscriberCid, campaignId, callback) => { - listId = Number(listId) || 0; - - campaignId = (campaignId || '').toString().trim() || false; - - if (listId < 1) { - return callback(new Error(_('Missing List ID'))); - } - - if (!subscriberCid) { - return callback(new Error(_('Missing subscriber cid'))); - } - - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - connection.query('SELECT * FROM `subscription__' + listId + '` WHERE `cid`=?', [subscriberCid], (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); @@ -632,17 +443,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; @@ -650,10 +455,7 @@ 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) { @@ -662,19 +464,13 @@ module.exports.changeStatus = (id, listId, campaignId, status, callback) => { 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); @@ -683,20 +479,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) { 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); @@ -705,10 +495,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; @@ -717,10 +504,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); @@ -730,10 +514,7 @@ 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 => { 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'; @@ -742,18 +523,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); @@ -805,19 +580,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) { 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); @@ -826,17 +595,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); @@ -1028,13 +791,13 @@ module.exports.listImports = (listId, callback) => { }); }; - -module.exports.updateAddress = (list, cid, updates, ip, 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'))); } @@ -1053,7 +816,7 @@ module.exports.updateAddress = (list, cid, updates, ip, 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`=? LIMIT 1'; let args = [cid]; connection.query(query, args, (err, rows) => { if (err) { @@ -1072,7 +835,7 @@ module.exports.updateAddress = (list, cid, updates, ip, 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`=1 LIMIT 1'; let args = [emailNew, cid]; connection.query(query, args, (err, rows) => { connection.release(); @@ -1080,18 +843,11 @@ module.exports.updateAddress = (list, cid, updates, ip, 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, ip, { - _action: 'update', - cid, - subscriber: old.id, - emailOld: old.email - }, callback); }); }); }); @@ -1099,106 +855,64 @@ module.exports.updateAddress = (list, cid, updates, ip, callback) => { }; -module.exports.sendMail = (list, email, template, subject, relativeUrls, mailOpts, subscription, callback) => { - db.getConnection((err, connection) => { - if (err) { - return callback(err); - } - - fields.list(list.id, (err, fieldList) => { - if (err) { - return callback(err); - } - - let encryptionKeys = []; - fields.getRow(fieldList, subscription).forEach(field => { - if (field.type === 'gpg' && field.value) { - encryptionKeys.push(field.value.trim()); - } - }); - - settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', '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(); - }); - }); - }); -}; - - /* -FIXME -function getUnsubscriptionMode = (listId, start, limit, callback) => { - listId = Number(listId) || 0; - if (!listId) { - return callback(new Error('Missing List ID')); - } - - tableHelpers.list('subscription__' + listId, ['*'], 'email', null, start, limit, (err, rows, total) => { - if (!err) { - rows = rows.map(row => tools.convertKeys(row)); + 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); } - return callback(err, rows, total); + connection.beginTransaction(err => { + if (err) { + connection.release(); + return callback(err); + } + + let query = 'SELECT `id` FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>? AND `status`=1 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, new Error(_('Email address already registered')), callback); + } + + let query = 'DELETE FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>?'; + let args = [emailNew, subscriptionId]; + connection.query(query, args, (err, rows) => { + if (err) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); + } + + let query = 'UPDATE `subscription__' + listId + '` SET `email`=? WHERE `id`=? LIMIT 1'; + let args = [emailNew, subscriptionId]; + connection.query(query, args, err => { + if (err) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); + } + + return connection.commit(err => { + if (err) { + return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); + } + connection.release(); + + return callback(); + }); + }); + }); + }); + }); }); + +}; + +module.exports.getUnsubscriptionMode = (list, subscriptionId) => { + // TODO: Once the unsubscription mode is customizable per segment, then this will be a good place to process it. + return list.unsubscriptionMode; }; -*/ \ No newline at end of file diff --git a/lib/subscription-mail-helpers.js b/lib/subscription-mail-helpers.js new file mode 100644 index 00000000..3211a6b9 --- /dev/null +++ b/lib/subscription-mail-helpers.js @@ -0,0 +1,166 @@ +'use strict'; + +const log = require('npmlog'); +const config = require('config'); +let db = require('./db'); +let fields = require('./models/fields'); +let settings = require('./models/settings'); +let mailer = require('./mailer'); +let urllib = require('url'); +let helpers = require('./helpers'); +let tools = require('./tools'); +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 + }; + + subscriptions.sendMail(list, email, 'subscription-confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, data.subscriptionData, 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 + }; + module.exports.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/' + cid + }; + module.exports.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/' + cid + }; + module.exports.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/' + cid + }; + module.exports.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 + }; + subscriptions.sendMail(list, email, 'unsubscription-confirmed', _('%s: Unsubscribe Confirmed'), relativeUrls, {}, subscription, callback); +} + + +function sendMail(list, email, template, subject, relativeUrls, mailOpts, subscription, callback) { + db.getConnection((err, connection) => { + if (err) { + return callback(err); + } + + fields.list(list.id, (err, fieldList) => { + if (err) { + return callback(err); + } + + let encryptionKeys = []; + fields.getRow(fieldList, subscription).forEach(field => { + if (field.type === 'gpg' && field.value) { + encryptionKeys.push(field.value.trim()); + } + }); + + settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', '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/routes/forms.js b/routes/forms.js index 6c3935ac..ef10bd9a 100644 --- a/routes/forms.js +++ b/routes/forms.js @@ -266,6 +266,11 @@ router.get('/:list/edit/:form', passport.csrfProtection, (req, res) => { 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/subscription.js b/routes/subscription.js index 193ff63f..d3e712e9 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 || []; @@ -45,7 +46,7 @@ let corsOrCsrfProtection = (req, res, next) => { }; router.get('/confirm/:cid', (req, res, next) => { - subscriptions.processConfirmation(req.params.cid, req.ip, (err, subscription, action) => { + confirmations.takeConfirmation(req.params.cid, (err, confirmation) => { if (!err && !subscription) { err = new Error(_('Selected subscription not found')); err.status = 404; @@ -55,7 +56,9 @@ router.get('/confirm/:cid', (req, res, next) => { return next(err); } - lists.get(subscription.list, (err, list) => { + const data = confirmation.data; + + lists.get(confirmation.listId, (err, list) => { if (!err && !list) { err = new Error(_('Selected list not found')); err.status = 404; @@ -65,23 +68,89 @@ router.get('/confirm/:cid', (req, res, next) => { return next(err); } - // FIXME - differentiate email based on action - const relativeUrls = { - preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid, - unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid - }; - - subscriptions.sendMail(list, subscription.email, 'subscription-confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, subscription, (err) => { - 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)); + if (confirmation.action === 'change-address') { + 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'))); } - res.redirect('/subscription/' + list.cid + '/subscribed-notice'); - }); + subscriptions.updateAddress(list.id, data.subscriptionId, data.emailNew, err => { + if (err) { + return next(err); + } + subscriptions.getById(list.id, subscriptionId, (err, subscription) => { + if (err) { + return next(err); + } + + mailHelpers.sendSubscriptionConfirmed(list, data.emailNew, subscription, err => { + if (err) { + return next(err); + } + + res.redirect('/subscription/' + list.cid + '/manage-address/' + subscription.cid); + }); + }); + }); + + } else if (confirmation.action === 'subscribe') { + let optInCountry = geoip.lookupCountry(confirmation.ip) || null; + + const meta = { + email: data.email, + optInIp: confirmation.ip, + optInCountry, + status: 1 + }; + + 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); + } + + mailHelpers.sendSubscriptionConfirmed(list, data.email, subscription, err => { + if (err) { + return next(err); + } + + res.redirect('/subscription/' + list.cid + '/subscribed-notice'); + }); + }); + }); + + } else if (confirmation.action === 'unsubscribe') { + subscriptions.changeStatus(list.id, confirmation.data.subscriptionId, confirmation.data.campaignId, 2, (err, found) => { + if (err) { + return next(err); + } + + // TODO: Shall we do anything with "found"? + + subscriptions.getById(list.id, confirmation.data.subscriptionId, (err, subscription) => { + if (err) { + return next(err); + } + + mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => { + if (err) { + return next(err); + } + + res.redirect('/subscription/' + req.params.lcid + '/unsubscribed-notice'); + }); + }); + }); + } }); }); }); @@ -262,7 +331,7 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r } // Check if the subscriber seems legit. This is a really simple check, the only requirement is that - // the subsciber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this + // the subscriber has JavaScript turned on and thats it. If Mailtrain gets more targeted then this // simple check should be replaced with an actual captcha let subTime = Number(req.body.sub) || 0; // allow clock skew 24h in the past and 24h to the future @@ -285,39 +354,67 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r return req.xhr ? sendJsonError(err) : next(err); } - let data = {}; + let subscriptionData = {}; Object.keys(req.body).forEach(key => { if (key !== 'email' && key.charAt(0) !== '_') { - data[key] = (req.body[key] || '').toString().trim(); + subscriptionData[key] = (req.body[key] || '').toString().trim(); } }); + subscriptionData = tools.convertKeys(data); - data = tools.convertKeys(data); - - data._address = req.body.address; - data._sub = req.body.sub; - data._skip = !testsPass; - data._action = 'subscribe'; - - subscriptions.addConfirmation(list, email, req.ip, data, (err, confirmCid) => { - if (!err && !confirmCid) { - err = new Error(_('Could not store confirmation data')); - } - + subscriptions.getByEmail(list.id, email, (err, subscription) => { 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') + if (subscription) { + 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 && !confirmCid) { + err = new Error(_('Could not store confirmation data')); + } + + if (err) { + if (req.xhr) { + return sendJsonError(err); + } + req.flash('danger', err.message || err); + return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body)); + } + + 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 { + sendConfirmSubscription(list, email, confirmCid, data, (err) => { + if (err) { + return req.xhr ? sendJsonError(err) : sendWebResponse(err); + } + sendWebResponse(); + }) + } }); } - res.redirect('/subscription/' + req.params.cid + '/confirm-subscription-notice'); }); }); }); @@ -492,15 +589,41 @@ router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection return next(err); } - subscriptions.updateAddress(list, req.body.cid, req.body, req.ip, err => { + const emailNew = (req.body.emailNew || '').toString().trim(); + + subscriptions.updateAddressCheck(list, req.body.cid, emailNew, req.ip, (err, subscription, newEmailAvailable) => { if (err) { req.flash('danger', err.message || err); log.error('Subscription', err); return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/manage-address/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body)); } - 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); + 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 => { + if (err) { + return next(err); + } + + mailHelpers.sendConfirmAddressChange(list, emailNew, subscription, sendWebResponse); + }); + + } else { + mailHelpers.sendAlreadySubscribed(list, emailNew, subscription, sendWebResponse); + } }); }); }); @@ -525,7 +648,7 @@ 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')); + err = new Error(_('Subscription not found in this list')); err.status = 404; } @@ -533,40 +656,47 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) return next(err); } - 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' - }; + if (list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM || + list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) { - helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-unsubscribe', subscription, (err, data) => { - if (err) { - return next(err); - } + 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; - helpers.getMjmlTemplate(data.template, (err, htmlRenderer) => { + 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, res); + } }); }); }); @@ -583,47 +713,95 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, ( return next(err); } - subscriptions.unsubscribe(list.id, req.body.ucid, 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.ucid) + '?' + tools.queryParams(req.body)); + const campaignId = (req.body.campaign || '').toString().trim() || false; + + subscriptions.get(list.id, req.body.ucid, (err, subscription) => { + if (!err && !subscription) { + err = new Error(_('Subscription not found in this list')); + err.status = 404; } - const relativeUrls = { - subscribeUrl: '/subscription/' + list.cid + '?cid=' + subscription.cid - }; - subscriptions.sendMail(list, subscription.email, 'unsubscription-confirmed', _('%s: Unsubscribe Confirmed'), relativeUrls, {}, subscription, (err) => { - if (err) { - req.flash('danger', err.message || err); - log.error('Subscription', err); - return res.redirect('/subscription/' + encodeURIComponent(list.cid) + '/unsubscribe/' + encodeURIComponent(subscription.cid) + '?' + tools.queryParams(req.body)); - } + if (err) { + return next(err); + } - res.redirect('/subscription/' + req.params.lcid + '/unsubscribed-notice'); - }); + handleUnsubscribe(list, subscription, res); }); }); }); +function handleUnsubscribe(list, subscription, res) { + 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', req.ip, data, (err, confirmCid) => { + if (!err && !confirmCid) { + err = new Error(_('Could not store confirmation data')); + } + + if (err) { + return next(err); + } + + mailHelpers.sendConfirmUnsubscription(list, subscription.email, subscription, err => { + if (err) { + return next(err); + } + + res.redirect('/subscription/' + list.cid + '/unsubscribed-notice'); + }); + }); + + } else if (list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || + list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) { + + subscriptions.changeStatus(subscription.id, list.id, campaignId, 2, (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 { // UnsubscriptionMode.MANUAL + res.redirect('/subscription/' + list.cid + '/manual-unsubscribe-notice'); + } +} + router.get('/:cid/confirm-subscription-notice', (req, res, next) => { - notice('confirm-subscription', req, res, next); + webNotice('confirm-subscription', req, res, next); }); router.get('/:cid/confirm-unsubscription-notice', (req, res, next) => { - notice('confirm-unsubscription', req, res, next); + webNotice('confirm-unsubscription', req, res, next); }); router.get('/:cid/subscribed-notice', (req, res, next) => { - notice('subscribed', req, res, next); + webNotice('subscribed', req, res, next); }); router.get('/:cid/updated-notice', (req, res, next) => { - notice('updated', req, res, next); + webNotice('updated', req, res, next); }); router.get('/:cid/unsubscribed-notice', (req, res, next) => { - notice('unsubscribed', 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) => { @@ -665,7 +843,7 @@ router.post('/publickey', passport.parseForm, (req, res, next) => { }); -function notice(type, 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')); @@ -676,7 +854,7 @@ function notice(type, req, res, next) { return next(err); } - settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress'], (err, configItems) => { + settings.list(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress', 'adminEmail'], (err, configItems) => { if (err) { return next(err); } @@ -686,6 +864,7 @@ function notice(type, req, res, next) { 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' @@ -718,5 +897,4 @@ function notice(type, req, res, next) { }); } - module.exports = router; diff --git a/setup/sql/upgrade-00028.sql b/setup/sql/upgrade-00028.sql index 8ea8bdbc..82d38d21 100644 --- a/setup/sql/upgrade-00028.sql +++ b/setup/sql/upgrade-00028.sql @@ -5,8 +5,17 @@ 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"; diff --git a/views/subscription/mail-already-subscribed-html.mjml.hbs b/views/subscription/mail-already-subscribed-html.mjml.hbs index d4a9b20e..83e7eb1a 100644 --- a/views/subscription/mail-already-subscribed-html.mjml.hbs +++ b/views/subscription/mail-already-subscribed-html.mjml.hbs @@ -1,7 +1,7 @@ - {{#translate}}Email address already subscribed{{/translate}} + {{#translate}}Email address already registered{{/translate}} {{#translate}}We have received a subscription request. Your email address is however already registered.{{/translate}}. diff --git a/views/subscription/mail-already-subscribed-text.hbs b/views/subscription/mail-already-subscribed-text.hbs index fe982896..5c273dfe 100644 --- a/views/subscription/mail-already-subscribed-text.hbs +++ b/views/subscription/mail-already-subscribed-text.hbs @@ -1,5 +1,5 @@ {{{title}}} -{{#translate}}Email address already subscribed{{/translate}} +{{#translate}}Email address already registered{{/translate}} ================================ {{#translate}}We have received a subscription request. Your email address is however already registered.{{/translate}} diff --git a/views/subscription/web-manual-unsubscribe-notice.mjml.hbs b/views/subscription/web-manual-unsubscribe-notice.mjml.hbs new file mode 100644 index 00000000..7dfe1609 --- /dev/null +++ b/views/subscription/web-manual-unsubscribe-notice.mjml.hbs @@ -0,0 +1,13 @@ + + + + {{#translate}}Online Unsubscription Is Not Possible{{/translate}} + + + {{#translate}}Please contact us at{{/translate}} {{contactAddress}} {{#translate}}to get removed from the list{{/translate}}. + + + {{#translate}}Return to our website{{/translate}} + + + From a6d25e668b6d9a31b90b05bb9bfe7d65f1d619a1 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Thu, 4 May 2017 17:42:46 -0400 Subject: [PATCH 04/11] Release candidate of the selectable unsubscription Implemented the resubscription process - i.e. pre-filling in the form when the subscription link is clicked in the unsubscription notice. --- languages/de_DE.po | 2 +- lib/helpers.js | 2 +- lib/models/campaigns.js | 16 +- lib/models/confirmations.js | 4 +- lib/models/reports.js | 3 +- lib/models/settings.js | 2 + lib/models/subscriptions.js | 51 +++-- lib/subscription-mail-helpers.js | 135 ++++++----- routes/subscription.js | 210 ++++++++++-------- views/lists/forms/edit.hbs | 4 + .../partials/subscription-custom-fields.hbs | 2 +- .../partials/subscription-subscribe-form.hbs | 1 + 12 files changed, 238 insertions(+), 194 deletions(-) diff --git a/languages/de_DE.po b/languages/de_DE.po index 69020cc7..73774df1 100644 --- a/languages/de_DE.po +++ b/languages/de_DE.po @@ -4030,7 +4030,7 @@ msgstr "" "Bestätigung" #: routes/subscription.js:730 -msgid "%s: Unsubscribe Confirmed" +msgid "%s: Unsubscription Confirmed" msgstr "%s: Abmeldungen Bestätigt" #: routes/subscription.js:777 routes/subscription.js:793 diff --git a/lib/helpers.js b/lib/helpers.js index 8324a811..70a9b232 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -98,7 +98,7 @@ function filterCustomFields(customFieldsIn = [], fieldIds = [], method = 'includ id: 'email', name: 'Email Address', type: 'Email', - typeSubsciptionEmail: true + typeSubscriptionEmail: true }, { id: 'firstname', name: 'First Name', diff --git a/lib/models/campaigns.js b/lib/models/campaigns.js index c25c57ee..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.list, message.subscription, 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 index 66025ed1..dbac8497 100644 --- a/lib/models/confirmations.js +++ b/lib/models/confirmations.js @@ -16,14 +16,14 @@ module.exports.addConfirmation = (listId, action, ip, data, callback) => { } let query = 'INSERT INTO confirmations (cid, list, action, ip, data) VALUES (?,?,?,?,?)'; - connection.query(query, [cid, list, action, ip, JSON.stringify(data || {})], (err, result) => { + 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(null, false); + return callback(new Error(_('Could not store confirmation data'))); } return callback(null, cid); diff --git a/lib/models/reports.js b/lib/models/reports.js index aac2d967..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; 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 08f2cbac..34147901 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -12,6 +12,16 @@ 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) { @@ -152,19 +162,19 @@ module.exports.insert = (listId, meta, subscriptionData, 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 && !meta.partial) { - return helpers.rollbackAndReleaseConnection(connection, new Error(_('Email address already registered')), callback); + 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) { @@ -458,8 +468,8 @@ module.exports.changeStatus = (listId, id, campaignId, status, callback) => { 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 => { @@ -483,7 +493,7 @@ module.exports.changeStatus = (listId, id, campaignId, status, callback) => { } // 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 helpers.rollbackAndReleaseConnection(connection, () => callback(err)); @@ -512,7 +522,7 @@ module.exports.changeStatus = (listId, id, 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 helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } @@ -583,7 +593,7 @@ module.exports.delete = (listId, cid, callback) => { return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } - if (subscription.status !== 1) { + if (subscription.status !== Status.SUBSCRIBED) { return connection.commit(err => { if (err) { return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); @@ -679,11 +689,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); }); }); }; @@ -816,7 +825,7 @@ module.exports.updateAddressCheck = (list, cid, emailNew, ip, callback) => { return callback(err); } - let query = 'SELECT * 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) { @@ -835,7 +844,7 @@ module.exports.updateAddressCheck = (list, cid, emailNew, ip, callback) => { let old = rows[0]; - let query = 'SELECT `id` FROM `subscription__' + list.id + '` WHERE `email`=? AND `cid`<>? AND `status`=1 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(); @@ -870,7 +879,7 @@ module.exports.updateAddress = (listId, subscriptionId, emailNew, callback) => { return callback(err); } - let query = 'SELECT `id` FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>? AND `status`=1 LIMIT 1'; + 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) { @@ -878,7 +887,7 @@ module.exports.updateAddress = (listId, subscriptionId, emailNew, callback) => { } if (rows && rows.length > 0) { - return helpers.rollbackAndReleaseConnection(connection, new Error(_('Email address already registered')), callback); + return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Email address already registered')))); } let query = 'DELETE FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>?'; @@ -888,13 +897,17 @@ module.exports.updateAddress = (listId, subscriptionId, emailNew, callback) => { return helpers.rollbackAndReleaseConnection(connection, () => callback(err)); } - let query = 'UPDATE `subscription__' + listId + '` SET `email`=? WHERE `id`=? LIMIT 1'; + let query = 'UPDATE `subscription__' + listId + '` SET `email`=? WHERE `id`=? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1'; let args = [emailNew, subscriptionId]; - connection.query(query, args, err => { + 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)); diff --git a/lib/subscription-mail-helpers.js b/lib/subscription-mail-helpers.js index 3211a6b9..96beb978 100644 --- a/lib/subscription-mail-helpers.js +++ b/lib/subscription-mail-helpers.js @@ -2,7 +2,6 @@ const log = require('npmlog'); const config = require('config'); -let db = require('./db'); let fields = require('./models/fields'); let settings = require('./models/settings'); let mailer = require('./mailer'); @@ -28,7 +27,7 @@ function sendSubscriptionConfirmed(list, email, subscription, callback) { unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid }; - subscriptions.sendMail(list, email, 'subscription-confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, data.subscriptionData, callback); + sendMail(list, email, 'subscription-confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, subscription, callback); } function sendAlreadySubscribed(list, email, subscription, callback) { @@ -39,7 +38,7 @@ function sendAlreadySubscribed(list, email, subscription, callback) { preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid, unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid }; - module.exports.sendMail(list, email, 'already-subscribed', _('%s: Email Address Already Registered'), relativeUrls, mailOpts, subscription, callback); + sendMail(list, email, 'already-subscribed', _('%s: Email Address Already Registered'), relativeUrls, mailOpts, subscription, callback); } function sendConfirmAddressChange(list, email, cid, subscription, callback) { @@ -49,7 +48,7 @@ function sendConfirmAddressChange(list, email, cid, subscription, callback) { const relativeUrls = { confirmUrl: '/subscription/confirm/' + cid }; - module.exports.sendMail(list, email, 'confirm-address-change', _('%s: Please Confirm Email Change in Subscription'), relativeUrls, mailOpts, subscription, callback); + sendMail(list, email, 'confirm-address-change', _('%s: Please Confirm Email Change in Subscription'), relativeUrls, mailOpts, subscription, callback); } function sendConfirmSubscription(list, email, cid, subscription, callback) { @@ -59,7 +58,7 @@ function sendConfirmSubscription(list, email, cid, subscription, callback) { const relativeUrls = { confirmUrl: '/subscription/confirm/' + cid }; - module.exports.sendMail(list, email, 'confirm-subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, subscription, callback); + sendMail(list, email, 'confirm-subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, subscription, callback); } function sendConfirmUnsubscription(list, email, cid, subscription, callback) { @@ -69,98 +68,92 @@ function sendConfirmUnsubscription(list, email, cid, subscription, callback) { const relativeUrls = { confirmUrl: '/subscription/confirm/' + cid }; - module.exports.sendMail(list, email, 'confirm-unsubscription', _('%s: Please Confirm Unsubscription'), relativeUrls, mailOpts, subscription, callback); + 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 }; - subscriptions.sendMail(list, email, 'unsubscription-confirmed', _('%s: Unsubscribe Confirmed'), relativeUrls, {}, subscription, callback); + sendMail(list, email, 'unsubscription-confirmed', _('%s: Unsubscription Confirmed'), relativeUrls, {}, subscription, callback); } function sendMail(list, email, template, subject, relativeUrls, mailOpts, subscription, callback) { - db.getConnection((err, connection) => { + fields.list(list.id, (err, fieldList) => { if (err) { return callback(err); } - fields.list(list.id, (err, fieldList) => { + 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); } - let encryptionKeys = []; - fields.getRow(fieldList, subscription).forEach(field => { - if (field.type === 'gpg' && field.value) { - encryptionKeys.push(field.value.trim()); - } - }); + if (!mailOpts.ignoreDisableConfirmations && configItems.disableConfirmations) { + return; + } - settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', 'disableConfirmations'], (err, configItems) => { - if (err) { - return callback(err); - } + const data = { + title: list.name, + homepage: configItems.defaultHomepage || configItems.serviceUrl, + contactAddress: configItems.defaultAddress, + defaultPostaddress: configItems.defaultPostaddress, + }; - if (!mailOpts.ignoreDisableConfirmations && configItems.disableConfirmations) { - return; - } + for (let relativeUrlKey in relativeUrls) { + data[relativeUrlKey] = urllib.resolve(configItems.serviceUrl, relativeUrls[relativeUrlKey]); + } - 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) => { + 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) { - return sendMail(html, text); + log.error('Subscription', err); } - - sendMail(tmpl.html, tmpl.text); }); + } - return callback(); + 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/routes/subscription.js b/routes/subscription.js index d3e712e9..9790eb72 100644 --- a/routes/subscription.js +++ b/routes/subscription.js @@ -47,7 +47,7 @@ let corsOrCsrfProtection = (req, res, next) => { router.get('/confirm/:cid', (req, res, next) => { confirmations.takeConfirmation(req.params.cid, (err, confirmation) => { - if (!err && !subscription) { + if (!err && !confirmation) { err = new Error(_('Selected subscription not found')); err.status = 404; } @@ -79,7 +79,7 @@ router.get('/confirm/:cid', (req, res, next) => { return next(err); } - subscriptions.getById(list.id, subscriptionId, (err, subscription) => { + subscriptions.getById(list.id, data.subscriptionId, (err, subscription) => { if (err) { return next(err); } @@ -89,7 +89,8 @@ router.get('/confirm/:cid', (req, res, next) => { return next(err); } - res.redirect('/subscription/' + list.cid + '/manage-address/' + subscription.cid); + req.flash('info', _('Email address changed')); + res.redirect('/subscription/' + list.cid + '/manage/' + subscription.cid); }); }); }); @@ -101,7 +102,7 @@ router.get('/confirm/:cid', (req, res, next) => { email: data.email, optInIp: confirmation.ip, optInCountry, - status: 1 + status: subscriptions.Status.SUBSCRIBED }; subscriptions.insert(list.id, meta, data.subscriptionData, (err, result) => { @@ -129,7 +130,7 @@ router.get('/confirm/:cid', (req, res, next) => { }); } else if (confirmation.action === 'unsubscribe') { - subscriptions.changeStatus(list.id, confirmation.data.subscriptionId, confirmation.data.campaignId, 2, (err, found) => { + subscriptions.changeStatus(list.id, confirmation.data.subscriptionId, confirmation.data.campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => { if (err) { return next(err); } @@ -146,7 +147,7 @@ router.get('/confirm/:cid', (req, res, next) => { return next(err); } - res.redirect('/subscription/' + req.params.lcid + '/unsubscribed-notice'); + res.redirect('/subscription/' + list.cid + '/unsubscribed-notice'); }); }); }); @@ -171,7 +172,7 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => { return next(err); } - // FIXME: process subscriber cid param for resubscription requests + // TODO: process subscriber cid param for resubscription requests let data = tools.convertKeys(req.query, { skip: ['layout'] @@ -181,51 +182,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(); + } }); }); @@ -360,14 +384,14 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r subscriptionData[key] = (req.body[key] || '').toString().trim(); } }); - subscriptionData = tools.convertKeys(data); + subscriptionData = tools.convertKeys(subscriptionData); subscriptions.getByEmail(list.id, email, (err, subscription) => { if (err) { return req.xhr ? sendJsonError(err) : next(err); } - if (subscription) { + if (subscription && subscription.status === subscriptions.Status.SUBSCRIBED) { mailHelpers.sendAlreadySubscribed(list, email, subscription, (err) => { if (err) { return req.xhr ? sendJsonError(err) : next(err); @@ -381,10 +405,6 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r }; confirmations.addConfirmation(list.id, 'subscribe', req.ip, data, (err, confirmCid) => { - if (!err && !confirmCid) { - err = new Error(_('Could not store confirmation data')); - } - if (err) { if (req.xhr) { return sendJsonError(err); @@ -406,7 +426,7 @@ router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, r log.info('Subscription', 'Confirmation message for %s marked to be skipped (%s)', email, JSON.stringify(data)); sendWebResponse(); } else { - sendConfirmSubscription(list, email, confirmCid, data, (err) => { + mailHelpers.sendConfirmSubscription(list, email, confirmCid, data, (err) => { if (err) { return req.xhr ? sendJsonError(err) : sendWebResponse(err); } @@ -436,8 +456,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; } @@ -507,13 +527,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'); + }); }); }); }); @@ -535,8 +564,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; } @@ -589,48 +618,52 @@ router.post('/:lcid/manage-address', passport.parseForm, passport.csrfProtection return next(err); } - const emailNew = (req.body.emailNew || '').toString().trim(); + 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(); - subscriptions.updateAddressCheck(list, req.body.cid, emailNew, req.ip, (err, subscription, newEmailAvailable) => { - if (err) { - req.flash('danger', err.message || err); - log.error('Subscription', err); - return res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/manage-address/' + encodeURIComponent(req.body.cid) + '?' + tools.queryParams(req.body)); - } + if (emailOld === emailNew) { + req.flash('info', _('Nothing seems to be changed')); + res.redirect('/subscription/' + req.params.lcid + '/manage/' + req.body.cid); - function sendWebResponse(err) { + } else { + subscriptions.updateAddressCheck(list, req.body.cid, emailNew, req.ip, (err, subscription, newEmailAvailable) => { 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 => { + function sendWebResponse(err) { if (err) { return next(err); } - mailHelpers.sendConfirmAddressChange(list, emailNew, subscription, sendWebResponse); - }); + 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); + } - } else { - mailHelpers.sendAlreadySubscribed(list, emailNew, subscription, sendWebResponse); - } - }); + 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); + } + }); + } }); }); router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) => { - // FIXME: handle different subscription options. The one below is currently "One-step with unsubscribe form" - lists.getByCid(req.params.lcid, (err, list) => { if (!err && !list) { err = new Error(_('Selected list not found')); @@ -647,7 +680,7 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) } subscriptions.get(list.id, req.params.ucid, (err, subscription) => { - if (!err && !subscription) { + if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) { err = new Error(_('Subscription not found in this list')); err.status = 404; } @@ -657,7 +690,8 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) } - if (list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM || + 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; @@ -695,7 +729,7 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next) }); }); } else { // UnsubscriptionMode.ONE_STEP || UnsubscriptionMode.TWO_STEP || UnsubscriptionMode.MANUAL - handleUnsubscribe(list, subscription, res); + handleUnsubscribe(list, subscription, req.query.c, req.ip, res, next); } }); }); @@ -716,7 +750,7 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, ( const campaignId = (req.body.campaign || '').toString().trim() || false; subscriptions.get(list.id, req.body.ucid, (err, subscription) => { - if (!err && !subscription) { + if (!err && (!subscription || subscription.status !== subscriptions.Status.SUBSCRIBED)) { err = new Error(_('Subscription not found in this list')); err.status = 404; } @@ -725,12 +759,12 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, ( return next(err); } - handleUnsubscribe(list, subscription, res); + handleUnsubscribe(list, subscription, campaignId, req.ip, res, next); }); }); }); -function handleUnsubscribe(list, subscription, res) { +function handleUnsubscribe(list, subscription, campaignId, ip, res, next) { if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) { @@ -739,28 +773,24 @@ function handleUnsubscribe(list, subscription, res) { campaignId }; - confirmations.addConfirmation(list.id, 'unsubscribe', req.ip, data, (err, confirmCid) => { - if (!err && !confirmCid) { - err = new Error(_('Could not store confirmation data')); - } - + confirmations.addConfirmation(list.id, 'unsubscribe', ip, data, (err, confirmCid) => { if (err) { return next(err); } - mailHelpers.sendConfirmUnsubscription(list, subscription.email, subscription, err => { + mailHelpers.sendConfirmUnsubscription(list, subscription.email, confirmCid, subscription, err => { if (err) { return next(err); } - res.redirect('/subscription/' + list.cid + '/unsubscribed-notice'); + res.redirect('/subscription/' + list.cid + '/confirm-unsubscription-notice'); }); }); } else if (list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) { - subscriptions.changeStatus(subscription.id, list.id, campaignId, 2, (err, found) => { + subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => { if (err) { return next(err); } diff --git a/views/lists/forms/edit.hbs b/views/lists/forms/edit.hbs index 5b91dcfb..da199096 100644 --- a/views/lists/forms/edit.hbs +++ b/views/lists/forms/edit.hbs @@ -55,7 +55,11 @@ {{#translate}}Updated Notice{{/translate}} | {{#translate}}Unsubscribed Notice{{/translate}} + | + {{#translate}}Manual Unsubscribe Notice{{/translate}} {{#if testUsers}} + | + {{#translate}}Unsubscribe{{/translate}} | {{#translate}}Manage{{/translate}} | diff --git a/views/subscription/partials/subscription-custom-fields.hbs b/views/subscription/partials/subscription-custom-fields.hbs index 847a169e..9e679b03 100644 --- a/views/subscription/partials/subscription-custom-fields.hbs +++ b/views/subscription/partials/subscription-custom-fields.hbs @@ -1,6 +1,6 @@ {{#each customFields}} - {{#if typeSubsciptionEmail}} + {{#if typeSubscriptionEmail}}