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.
This commit is contained in:
Tomas Bures 2017-05-03 15:46:49 -04:00
parent 32e2e61789
commit bd4961366f
13 changed files with 672 additions and 488 deletions

View file

@ -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();
});
}

View file

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

View file

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

View file

@ -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'
];

View file

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

View file

@ -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;
};
*/

View file

@ -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();
});
});
});
}

View file

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

View file

@ -4,10 +4,8 @@ let log = require('npmlog');
let config = require('config');
let tools = require('../lib/tools');
let helpers = require('../lib/helpers');
let mailer = require('../lib/mailer');
let passport = require('../lib/passport');
let express = require('express');
let urllib = require('url');
let router = new express.Router();
let lists = require('../lib/models/lists');
let fields = require('../lib/models/fields');
@ -18,6 +16,9 @@ let _ = require('../lib/translate')._;
let util = require('util');
let cors = require('cors');
let cache = require('memory-cache');
let geoip = require('geoip-ultralight');
let confirmations = require('../lib/models/confirmations');
let mailHelpers = require('../lib/subscription-mail-helpers');
let originWhitelist = config.cors && config.cors.origins || [];
@ -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;

View file

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

View file

@ -1,7 +1,7 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Email address already subscribed{{/translate}}
{{#translate}}Email address already registered{{/translate}}
</mj-text>
<mj-text mj-class="p">
{{#translate}}We have received a subscription request. Your email address is however already registered.{{/translate}}.

View file

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

View file

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