Work in progress on subscriptions

This commit is contained in:
Tomas Bures 2017-12-10 21:44:35 +01:00
parent eecb3cd067
commit b22a87e712
18 changed files with 1729 additions and 884 deletions

View file

@ -18,11 +18,6 @@ module.exports = {
getDefaultMergeTags,
getRSSMergeTags,
getListMergeTags,
captureFlashMessages,
injectCustomFormData,
injectCustomFormTemplates,
filterCustomFields,
getMjmlTemplate,
rollbackAndReleaseConnection,
filterObject,
enforce
@ -119,176 +114,7 @@ function getListMergeTags(listId, callback) {
});
}
function filterCustomFields(customFieldsIn = [], fieldIds = [], method = 'include') {
let customFields = customFieldsIn.slice();
fieldIds = typeof fieldIds === 'string' ? fieldIds.split(',') : fieldIds;
customFields.unshift({
id: 'email',
name: 'Email Address',
type: 'Email',
typeSubscriptionEmail: true
}, {
id: 'firstname',
name: 'First Name',
type: 'Text',
typeFirstName: true
}, {
id: 'lastname',
name: 'Last Name',
type: 'Text',
typeLastName: true
});
let filtered = [];
if (method === 'include') {
fieldIds.forEach(id => {
let field = customFields.find(f => f.id.toString() === id);
field && filtered.push(field);
});
} else {
customFields.forEach(field => {
!fieldIds.includes(field.id.toString()) && filtered.push(field);
});
}
return filtered;
}
function injectCustomFormData(customFormId, viewPath, data, callback) {
let injectDefaultData = data => {
data.customFields = filterCustomFields(data.customFields, [], 'exclude');
data.formInputStyle = '@import url(/subscription/form-input-style.css);';
return data;
};
if (Number(customFormId) < 1) {
return callback(null, injectDefaultData(data));
}
forms.get(customFormId, (err, form) => {
if (err) {
return callback(null, injectDefaultData(data));
}
let view = viewPath.split('/')[1];
if (view === 'web-subscribe') {
data.customFields = form.fieldsShownOnSubscribe
? filterCustomFields(data.customFields, form.fieldsShownOnSubscribe)
: filterCustomFields(data.customFields, [], 'exclude');
} else if (view === 'web-manage') {
data.customFields = form.fieldsShownOnManage
? filterCustomFields(data.customFields, form.fieldsShownOnManage)
: filterCustomFields(data.customFields, [], 'exclude');
}
let key = tools.fromDbKey(view);
data.template.template = form[key] || data.template.template;
data.template.layout = form.layout || data.template.layout;
data.formInputStyle = form.formInputStyle || '@import url(/subscription/form-input-style.css);';
settings.list(['ua_code'], (err, configItems) => {
if (err) {
return callback(err);
}
data.uaCode = configItems.uaCode;
data.customSubscriptionScripts = config.customsubscriptionscripts || [];
callback(null, data);
});
});
}
function injectCustomFormTemplates(customFormId, templates, callback) {
if (Number(customFormId) < 1) {
return callback(null, templates);
}
forms.get(customFormId, (err, form) => {
if (err) {
return callback(null, templates);
}
let lookUp = name => {
let key = tools.fromDbKey(
/subscription\/([^.]*)/.exec(name)[1]
);
return form[key] || name;
};
Object.keys(templates).forEach(key => {
let value = templates[key];
if (typeof value === 'string') {
templates[key] = lookUp(value);
}
if (typeof value === 'object' && value.template) {
templates[key].template = lookUp(value.template);
}
if (typeof value === 'object' && value.layout) {
templates[key].layout = lookUp(value.layout);
}
});
callback(null, templates);
});
}
function getMjmlTemplate(template, callback) {
if (!template) {
return callback(null, false);
}
let key = (typeof template === 'object') ? objectHash(template) : template;
if (mjmlTemplates.has(key)) {
return callback(null, mjmlTemplates.get(key));
}
let done = source => {
let compiled;
try {
compiled = mjml.mjml2html(source);
} catch (err) {
return callback(err);
}
if (compiled.errors.length) {
return callback(compiled.errors[0].message || compiled.errors[0]);
}
let renderer = hbs.handlebars.compile(compiled.html);
mjmlTemplates.set(key, renderer);
callback(null, renderer);
};
if (typeof template === 'object') {
tools.mergeTemplateIntoLayout(template.template, template.layout, (err, source) => {
if (err) {
return callback(err);
}
done(source);
});
} else {
fs.readFile(path.join(__dirname, '..', 'views', template), 'utf-8', (err, source) => {
if (err) {
return callback(err);
}
done(source);
});
}
}
function captureFlashMessages(req, res, callback) {
res.render('subscription/capture-flash-messages', { layout: null }, (err, flash) => {
if (err) {
return callback(err);
}
callback(null, flash);
});
}
// FIXME - remove once we get rid of non-async models
function rollbackAndReleaseConnection(connection, callback) {
connection.rollback(() => {
connection.release();

View file

@ -42,6 +42,38 @@ module.exports.loggedIn = (req, res, next) => {
}
};
module.exports.authByAccessToken = (req, res, next) => {
nodeifyPromise((async () => {
if (!req.query.access_token) {
res.status(403);
return res.json({
error: 'Missing access_token',
data: []
});
}
try {
const user = await users.getByAccessToken(req.query.access_token);
req.user = user;
next();
} catch (err) {
if (err instanceof interoperableErrors.NotFoundError) {
res.status(403);
return res.json({
error: 'Invalid or expired access_token',
data: []
});
} else {
res.status(500);
return res.json({
error: err.message || err,
data: []
});
}
}
})(), next);
};
module.exports.setup = app => {
app.use(passport.initialize());
app.use(passport.session());

View file

@ -1,13 +1,17 @@
'use strict';
const log = require('npmlog');
let fields = require('./models/fields');
let settings = require('./models/settings');
let mailer = require('./mailer');
let urllib = require('url');
let helpers = require('./helpers');
let _ = require('./translate')._;
let util = require('util');
const fields = require('../models/fields');
const settings = require('../models/settings');
const urllib = require('url');
const helpers = require('./helpers');
const _ = require('./translate')._;
const util = require('util');
const contextHelpers = require('./context-helpers');
const {getFieldKey} = require('../shared/lists');
const forms = require('../models/forms');
const bluebird = require('bluebird');
const sendMail = bluebird.promisify(require('./mailer').sendMail);
module.exports = {
@ -19,16 +23,16 @@ module.exports = {
sendUnsubscriptionConfirmed
};
function sendSubscriptionConfirmed(list, email, subscription, callback) {
async function sendSubscriptionConfirmed(list, email, subscription) {
const relativeUrls = {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid,
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
};
sendMail(list, email, 'subscription-confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, subscription, callback);
await sendMail(list, email, 'subscription_confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, subscription);
}
function sendAlreadySubscribed(list, email, subscription, callback) {
async function sendAlreadySubscribed(list, email, subscription) {
const mailOpts = {
ignoreDisableConfirmations: true
};
@ -36,120 +40,141 @@ function sendAlreadySubscribed(list, email, subscription, callback) {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid,
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
};
sendMail(list, email, 'already-subscribed', _('%s: Email Address Already Registered'), relativeUrls, mailOpts, subscription, callback);
await sendMail(list, email, 'already_subscribed', _('%s: Email Address Already Registered'), relativeUrls, mailOpts, subscription);
}
function sendConfirmAddressChange(list, email, cid, subscription, callback) {
async function sendConfirmAddressChange(list, email, cid, subscription) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
confirmUrl: '/subscription/confirm/change-address/' + cid
};
sendMail(list, email, 'confirm-address-change', _('%s: Please Confirm Email Change in Subscription'), relativeUrls, mailOpts, subscription, callback);
await sendMail(list, email, 'confirm_address_change', _('%s: Please Confirm Email Change in Subscription'), relativeUrls, mailOpts, subscription);
}
function sendConfirmSubscription(list, email, cid, subscription, callback) {
async function sendConfirmSubscription(list, email, cid, subscription) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
confirmUrl: '/subscription/confirm/subscribe/' + cid
};
sendMail(list, email, 'confirm-subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, subscription, callback);
await sendMail(list, email, 'confirm_subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, subscription);
}
function sendConfirmUnsubscription(list, email, cid, subscription, callback) {
async function sendConfirmUnsubscription(list, email, cid, subscription) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
confirmUrl: '/subscription/confirm/unsubscribe/' + cid
};
sendMail(list, email, 'confirm-unsubscription', _('%s: Please Confirm Unsubscription'), relativeUrls, mailOpts, subscription, callback);
await sendMail(list, email, 'confirm_unsubscription', _('%s: Please Confirm Unsubscription'), relativeUrls, mailOpts, subscription);
}
function sendUnsubscriptionConfirmed(list, email, subscription, callback) {
async function sendUnsubscriptionConfirmed(list, email, subscription) {
const relativeUrls = {
subscribeUrl: '/subscription/' + list.cid + '?cid=' + subscription.cid
};
sendMail(list, email, 'unsubscription-confirmed', _('%s: Unsubscription Confirmed'), relativeUrls, {}, subscription, callback);
await sendMail(list, email, 'unsubscription_confirmed', _('%s: Unsubscription Confirmed'), relativeUrls, {}, subscription);
}
function getDisplayName(flds, subscription) {
let firstName, lastName, name;
function sendMail(list, email, template, subject, relativeUrls, mailOpts, subscription, callback) {
fields.list(list.id, (err, fieldList) => {
if (err) {
return callback(err);
for (const fld of flds) {
if (fld.key === 'FIRST_NAME') {
firstName = subscription[fld.column];
}
let encryptionKeys = [];
fields.getRow(fieldList, subscription).forEach(field => {
if (field.type === 'gpg' && field.value) {
encryptionKeys.push(field.value.trim());
}
});
if (fld.key === 'LAST_NAME') {
lastName = subscription[fld.column];
}
settings.list(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', 'disableConfirmations'], (err, configItems) => {
if (err) {
return callback(err);
}
if (fld.key === 'NAME') {
name = subscription[fld.column];
}
}
if (!mailOpts.ignoreDisableConfirmations && configItems.disableConfirmations) {
return callback();
}
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]);
}
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 && tmpl) {
text = tmpl.text || text;
html = tmpl.html || html;
}
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);
}
});
callback();
});
});
});
if (name) {
return name;
} else if (firstName && lastName) {
return firstName + ' ' + lastName;
} else if (lastName) {
return lastName;
} else if (firstName) {
return firstName;
} else {
return '';
}
}
async function sendMail(list, email, template, subject, relativeUrls, mailOpts, subscription) {
console.log(subscription);
const flds = await fields.list(contextHelpers.getAdminContext(), list.id);
const encryptionKeys = [];
for (const fld of flds) {
if (fld.type === 'gpg' && field.value) {
encryptionKeys.push(subscription[getFieldKey(fld)].value.trim());
}
}
const configItems = await settings.get(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', 'disableConfirmations']);
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]);
}
const fsTemplate = template.replace(/_/g, '-');
const text = {
template: 'subscription/mail-' + fsTemplate + '-text.hbs'
};
const html = {
template: 'subscription/mail-' + fsTemplate + '-html.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',
type: 'mjml'
};
if (list.default_form !== null) {
const form = await forms.getById(contextHelpers.getAdminContext(), list.default_form);
text.template = form['mail_' + template + '_text'] || text.template;
html.template = form['mail_' + template + '_html'] || html.template;
html.layout = form.layout || html.layout;
}
try {
await sendMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: getDisplayName(flds, subscription),
address: email
},
subject: util.format(subject, list.name),
encryptionKeys
}, {
html,
text,
data
});
} catch (err) {
log.error('Subscription', err);
}
}

View file

@ -4,8 +4,12 @@ const _ = require('./translate')._;
const util = require('util');
const isemail = require('isemail');
const bluebird = require('bluebird');
const mergeTemplateIntoLayout = bluebird.promisify(require('./tools').mergeTemplateIntoLayout);
module.exports = {
validateEmail
validateEmail,
mergeTemplateIntoLayout
};
async function validateEmail(address, checkBlocked) {
@ -24,3 +28,4 @@ async function validateEmail(address, checkBlocked) {
return result;
}

View file

@ -18,9 +18,6 @@ let htmlToText = require('html-to-text');
let blockedUsers = ['abuse', 'admin', 'billing', 'compliance', 'devnull', 'dns', 'ftp', 'hostmaster', 'inoc', 'ispfeedback', 'ispsupport', 'listrequest', 'list', 'maildaemon', 'noc', 'noreply', 'noreply', 'null', 'phish', 'phishing', 'postmaster', 'privacy', 'registrar', 'root', 'security', 'spam', 'support', 'sysadmin', 'tech', 'undisclosedrecipients', 'unsubscribe', 'usenet', 'uucp', 'webmaster', 'www'];
module.exports = {
toDbKey,
fromDbKey,
convertKeys,
queryParams,
createSlug,
updateMenu,
@ -33,42 +30,6 @@ module.exports = {
workers: new Set()
};
function toDbKey(key) {
return key.
replace(/[^a-z0-9\-_]/gi, '').
replace(/-+/g, '_').
replace(/[A-Z]/g, c => '_' + c.toLowerCase()).
replace(/^_+|_+$/g, '').
replace(/_+/g, '_').
trim();
}
function fromDbKey(key) {
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) {
options = options || {};
let response = {};
Object.keys(obj || {}).forEach(key => {
let lKey = fromDbKey(key);
if (options.skip && options.skip.indexOf(lKey) >= 0) {
return;
}
if (options.keep && options.keep.indexOf(lKey) < 0) {
return;
}
response[lKey] = obj[key];
});
return response;
}
function queryParams(obj) {
return Object.keys(obj).
filter(key => key !== '_csrf').
@ -116,6 +77,7 @@ function createSlug(table, name, callback) {
});
}
// FIXME - remove once we fully manage the menu in the client
function updateMenu(res) {
if (!res.locals.menu) {
res.locals.menu = [];
@ -148,6 +110,7 @@ function updateMenu(res) {
}
}
// FIXME - either remove of delegate to validateEmail in tools-async (or vice-versa)
function validateEmail(address, checkBlocked, callback) {
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
if (checkBlocked && blockedUsers.indexOf(user) >= 0) {