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