WiP on mailers
This commit is contained in:
parent
e97415c237
commit
a4ee1534cc
46 changed files with 1263 additions and 529 deletions
388
lib/tools.js
388
lib/tools.js
|
@ -1,283 +1,155 @@
|
|||
'use strict';
|
||||
|
||||
const config = require('config');
|
||||
let fs = require('fs');
|
||||
let path = require('path');
|
||||
let db = require('./db');
|
||||
let slugify = require('slugify');
|
||||
let Isemail = require('isemail');
|
||||
let urllib = require('url');
|
||||
let juice = require('juice');
|
||||
let jsdom = require('jsdom');
|
||||
let he = require('he');
|
||||
let _ = require('./translate')._;
|
||||
let util = require('util');
|
||||
let createDOMPurify = require('dompurify');
|
||||
let htmlToText = require('html-to-text');
|
||||
const _ = require('./translate')._;
|
||||
const util = require('util');
|
||||
const isemail = require('isemail');
|
||||
|
||||
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'];
|
||||
const bluebird = require('bluebird');
|
||||
|
||||
module.exports = {
|
||||
queryParams,
|
||||
createSlug,
|
||||
updateMenu,
|
||||
validateEmail,
|
||||
formatMessage,
|
||||
getMessageLinks,
|
||||
prepareHtml,
|
||||
purifyHTML,
|
||||
mergeTemplateIntoLayout,
|
||||
workers: new Set()
|
||||
};
|
||||
const hasher = require('node-object-hash')();
|
||||
const mjml = require('mjml');
|
||||
const hbs = require('hbs');
|
||||
const juice = require('juice');
|
||||
|
||||
function queryParams(obj) {
|
||||
return Object.keys(obj).
|
||||
filter(key => key !== '_csrf').
|
||||
map(key => encodeURIComponent(key) + '=' + encodeURIComponent(obj[key])).
|
||||
join('&');
|
||||
}
|
||||
const fsReadFile = bluebird.promisify(require('fs').readFile);
|
||||
const jsdomEnv = bluebird.promisify(require('jsdom').env);
|
||||
|
||||
function createSlug(table, name, callback) {
|
||||
|
||||
let baseSlug = slugify(name).trim().toLowerCase() || 'list';
|
||||
let counter = 0;
|
||||
|
||||
if (baseSlug.length > 80) {
|
||||
baseSlug = baseSlug.substr(0, 80);
|
||||
}
|
||||
const templates = new Map();
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let finalize = (err, slug) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null, slug);
|
||||
};
|
||||
|
||||
let trySlug = () => {
|
||||
let currentSlug = baseSlug + (counter === 0 ? '' : '-' + counter);
|
||||
counter++;
|
||||
connection.query('SELECT id FROM ' + table + ' WHERE slug=?', [currentSlug], (err, rows) => {
|
||||
if (err) {
|
||||
return finalize(err);
|
||||
}
|
||||
if (!rows || !rows.length) {
|
||||
return finalize(null, currentSlug);
|
||||
}
|
||||
trySlug();
|
||||
});
|
||||
};
|
||||
|
||||
trySlug();
|
||||
});
|
||||
}
|
||||
|
||||
// FIXME - remove once we fully manage the menu in the client
|
||||
function updateMenu(res) {
|
||||
if (!res.locals.menu) {
|
||||
res.locals.menu = [];
|
||||
}
|
||||
|
||||
res.locals.menu.push({
|
||||
title: _('Lists'),
|
||||
url: '/lists',
|
||||
key: 'lists'
|
||||
}, {
|
||||
title: _('Templates'),
|
||||
url: '/templates',
|
||||
key: 'templates'
|
||||
}, {
|
||||
title: _('Campaigns'),
|
||||
url: '/campaigns',
|
||||
key: 'campaigns'
|
||||
}, {
|
||||
title: _('Automation'),
|
||||
url: '/triggers',
|
||||
key: 'triggers'
|
||||
});
|
||||
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
res.locals.menu.push({
|
||||
title: _('Reports'),
|
||||
url: '/reports',
|
||||
key: 'reports'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return callback(new Error(util.format(_('Blocked email address "%s"'), address)));
|
||||
}
|
||||
|
||||
Isemail.validate(address, {
|
||||
checkDNS: true,
|
||||
errorLevel: 1
|
||||
}, result => {
|
||||
|
||||
if (result !== 0) {
|
||||
let message = util.format(_('Invalid email address "%s".'), address);
|
||||
switch (result) {
|
||||
case 5:
|
||||
message += ' ' + _('MX record not found for domain');
|
||||
break;
|
||||
case 6:
|
||||
message += ' ' + _('Address domain not found');
|
||||
break;
|
||||
case 12:
|
||||
message += ' ' + _('Address domain name is required');
|
||||
break;
|
||||
}
|
||||
return callback(new Error(message));
|
||||
}
|
||||
|
||||
return callback();
|
||||
});
|
||||
}
|
||||
|
||||
function getMessageLinks(serviceUrl, campaign, list, subscription) {
|
||||
return {
|
||||
LINK_UNSUBSCRIBE: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?c=' + campaign.cid),
|
||||
LINK_PREFERENCES: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid),
|
||||
LINK_BROWSER: urllib.resolve(serviceUrl, '/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid),
|
||||
CAMPAIGN_ID: campaign.cid,
|
||||
LIST_ID: list.cid,
|
||||
SUBSCRIPTION_ID: subscription.cid
|
||||
};
|
||||
}
|
||||
|
||||
function formatMessage(serviceUrl, campaign, list, subscription, message, filter, isHTML) {
|
||||
filter = typeof filter === 'function' ? filter : (str => str);
|
||||
|
||||
let links = getMessageLinks(serviceUrl, campaign, list, subscription);
|
||||
|
||||
let getValue = key => {
|
||||
key = (key || '').toString().toUpperCase().trim();
|
||||
if (links.hasOwnProperty(key)) {
|
||||
return links[key];
|
||||
}
|
||||
if (subscription.mergeTags.hasOwnProperty(key)) {
|
||||
let value = (subscription.mergeTags[key] || '').toString();
|
||||
let containsHTML = /<[a-z][\s\S]*>/.test(value);
|
||||
return isHTML ? he.encode((containsHTML ? value : value.replace(/(?:\r\n|\r|\n)/g, '<br/>')), {
|
||||
useNamedReferences: true,
|
||||
allowUnsafeSymbols: true
|
||||
}) : (containsHTML ? htmlToText.fromString(value) : value);
|
||||
}
|
||||
async function getTemplate(template) {
|
||||
if (!template) {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
return message.replace(/\[([a-z0-9_]+)(?:\/([^\]]+))?\]/ig, (match, identifier, fallback) => {
|
||||
identifier = identifier.toUpperCase();
|
||||
let value = getValue(identifier);
|
||||
if (value === false) {
|
||||
return match;
|
||||
const key = (typeof template === 'object') ? hasher.hash(template) : template;
|
||||
|
||||
if (templates.has(key)) {
|
||||
return templates.get(key);
|
||||
}
|
||||
|
||||
let source;
|
||||
if (typeof template === 'object') {
|
||||
source = await mergeTemplateIntoLayout(template.template, template.layout);
|
||||
} else {
|
||||
source = await fsReadFile(path.join(__dirname, '..', 'views', template), 'utf-8');
|
||||
}
|
||||
|
||||
if (template.type === 'mjml') {
|
||||
const compiled = mjml.mjml2html(source);
|
||||
|
||||
if (compiled.errors.length) {
|
||||
throw new Error(compiled.errors[0].message || compiled.errors[0]);
|
||||
}
|
||||
value = (value || fallback || '').trim();
|
||||
return filter(value);
|
||||
});
|
||||
|
||||
source = compiled.html;
|
||||
}
|
||||
|
||||
const renderer = hbs.handlebars.compile(compiled.html);
|
||||
templates.set(key, renderer);
|
||||
|
||||
return renderer;
|
||||
}
|
||||
|
||||
function prepareHtml(html, callback) {
|
||||
if (!(html || '').toString().trim()) {
|
||||
return callback(null, false);
|
||||
|
||||
async function mergeTemplateIntoLayout(template, layout) {
|
||||
layout = layout || '{{{body}}}';
|
||||
|
||||
async function readFile(relPath) {
|
||||
return await fsReadFile(path.join(__dirname, '..', 'views', relPath), 'utf-8');
|
||||
}
|
||||
jsdom.env(false, false, {
|
||||
|
||||
// Please dont end your custom messages with .hbs ...
|
||||
if (layout.endsWith('.hbs')) {
|
||||
layout = await readFile(layout);
|
||||
}
|
||||
|
||||
if (template.endsWith('.hbs')) {
|
||||
template = readFile(template);
|
||||
}
|
||||
|
||||
const source = layout.replace(/\{\{\{body\}\}\}/g, template);
|
||||
return source;
|
||||
}
|
||||
|
||||
|
||||
async function validateEmail(address, checkBlocked) {
|
||||
let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
if (checkBlocked && blockedUsers.indexOf(user) >= 0) {
|
||||
throw new new Error(util.format(_('Blocked email address "%s"'), address));
|
||||
}
|
||||
|
||||
const result = await new Promise(resolve => {
|
||||
const result = isemail.validate(address, {
|
||||
checkDNS: true,
|
||||
errorLevel: 1
|
||||
}, resolve);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateEmailGetMessage(result, address) {
|
||||
if (result !== 0) {
|
||||
let message = util.format(_('Invalid email address "%s".'), address);
|
||||
switch (result) {
|
||||
case 5:
|
||||
message += ' ' + _('MX record not found for domain');
|
||||
break;
|
||||
case 6:
|
||||
message += ' ' + _('Address domain not found');
|
||||
break;
|
||||
case 12:
|
||||
message += ' ' + _('Address domain name is required');
|
||||
break;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareHtml(html) {
|
||||
if (!(html || '').toString().trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const win = await jsdomEnv(false, false, {
|
||||
html,
|
||||
features: {
|
||||
FetchExternalResources: false, // disables resource loading over HTTP / filesystem
|
||||
ProcessExternalResources: false // do not execute JS within script blocks
|
||||
}
|
||||
}, (err, win) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
})
|
||||
|
||||
let head = win.document.querySelector('head');
|
||||
let hasCharsetTag = false;
|
||||
let metaTags = win.document.querySelectorAll('meta');
|
||||
if (metaTags) {
|
||||
for (let i = 0; i < metaTags.length; i++) {
|
||||
if (metaTags[i].hasAttribute('charset')) {
|
||||
metaTags[i].setAttribute('charset', 'utf-8');
|
||||
hasCharsetTag = true;
|
||||
break;
|
||||
}
|
||||
const head = win.document.querySelector('head');
|
||||
let hasCharsetTag = false;
|
||||
const metaTags = win.document.querySelectorAll('meta');
|
||||
if (metaTags) {
|
||||
for (let i = 0; i < metaTags.length; i++) {
|
||||
if (metaTags[i].hasAttribute('charset')) {
|
||||
metaTags[i].setAttribute('charset', 'utf-8');
|
||||
hasCharsetTag = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasCharsetTag) {
|
||||
let charsetTag = win.document.createElement('meta');
|
||||
charsetTag.setAttribute('charset', 'utf-8');
|
||||
head.appendChild(charsetTag);
|
||||
}
|
||||
let preparedHtml = '<!doctype html><html>' + win.document.documentElement.innerHTML + '</html>';
|
||||
|
||||
return callback(null, juice(preparedHtml));
|
||||
});
|
||||
}
|
||||
|
||||
function purifyHTML(html) {
|
||||
let win = jsdom.jsdom('', {
|
||||
features: {
|
||||
FetchExternalResources: false, // disables resource loading over HTTP / filesystem
|
||||
ProcessExternalResources: false // do not execute JS within script blocks
|
||||
}
|
||||
}).defaultView;
|
||||
let DOMPurify = createDOMPurify(win);
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
// TODO Simplify!
|
||||
function mergeTemplateIntoLayout(template, layout, callback) {
|
||||
|
||||
layout = layout || '{{{body}}}';
|
||||
|
||||
let readFile = (relPath, callback) => {
|
||||
fs.readFile(path.join(__dirname, '..', 'views', relPath), 'utf-8', (err, source) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
callback(null, source);
|
||||
});
|
||||
};
|
||||
|
||||
let done = (template, layout) => {
|
||||
let source = layout.replace(/\{\{\{body\}\}\}/g, template);
|
||||
return callback(null, source);
|
||||
};
|
||||
|
||||
if (layout.endsWith('.hbs')) {
|
||||
readFile(layout, (err, layout) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
// Please dont end your custom messages with .hbs ...
|
||||
if (template.endsWith('.hbs')) {
|
||||
readFile(template, (err, template) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return done(template, layout);
|
||||
});
|
||||
} else {
|
||||
return done(template, layout);
|
||||
}
|
||||
});
|
||||
} else if (template.endsWith('.hbs')) {
|
||||
readFile(template, (err, template) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
return done(template, layout);
|
||||
});
|
||||
} else {
|
||||
return done(template, layout);
|
||||
}
|
||||
if (!hasCharsetTag) {
|
||||
const charsetTag = win.document.createElement('meta');
|
||||
charsetTag.setAttribute('charset', 'utf-8');
|
||||
head.appendChild(charsetTag);
|
||||
}
|
||||
const preparedHtml = '<!doctype html><html>' + win.document.documentElement.innerHTML + '</html>';
|
||||
|
||||
return juice(preparedHtml);
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
validateEmail,
|
||||
validateEmailGetMessage,
|
||||
mergeTemplateIntoLayout,
|
||||
getTemplate,
|
||||
prepareHtml
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue