WiP on mailers

This commit is contained in:
Tomas Bures 2018-04-29 18:13:40 +02:00
parent e97415c237
commit a4ee1534cc
46 changed files with 1263 additions and 529 deletions

114
lib/db.js
View file

@ -1,114 +0,0 @@
'use strict';
let config = require('config');
let mysql = require('mysql2');
let redis = require('redis');
let Lock = require('redfour');
let stringifyDate = require('json-stringify-date');
let senders = require('./senders');
module.exports = mysql.createPool(config.mysql);
if (config.redis && config.redis.enabled) {
module.exports.redis = redis.createClient(config.redis);
let queueLock = new Lock({
redis: config.redis,
namespace: 'mailtrain:lock'
});
module.exports.getLock = (id, callback) => {
queueLock.waitAcquireLock(id, 60 * 1000 /* Lock expires after 60sec */ , 10 * 1000 /* Wait for lock for up to 10sec */ , (err, lock) => {
if (err) {
return callback(err);
}
if (!lock) {
return callback(null, false);
}
return callback(null, {
lock,
release(done) {
queueLock.releaseLock(lock, done);
}
});
});
};
module.exports.clearCache = (key, callback) => {
module.exports.redis.del('mailtrain:cache:' + key, err => callback(err));
};
module.exports.addToCache = (key, value, callback) => {
if (!value) {
return setImmediate(() => callback());
}
module.exports.redis.multi().
lpush('mailtrain:cache:' + key, stringifyDate.stringify(value)).
expire('mailtrain:cache:' + key, 24 * 3600).
exec(err => callback(err));
};
module.exports.getFromCache = (key, callback) => {
module.exports.redis.rpop('mailtrain:cache:' + key, (err, value) => {
if (err) {
return callback(err);
}
try {
value = stringifyDate.parse(value);
} catch (E) {
return callback(E);
}
return callback(null, value);
});
};
} else {
// fakelock. does not lock anything
module.exports.getLock = (id, callback) => {
setImmediate(() => callback(null, {
lock: false,
release(done) {
setImmediate(done);
}
}));
};
let caches = new Map();
module.exports.clearCache = (key, callback) => {
caches.delete(key);
senders.workers.forEach(child => {
child.send({
cmd: 'db.clearCache',
key
});
});
setImmediate(() => callback());
};
process.on('message', m => {
if (m && m.cmd === 'db.clearCache' && m.key) {
caches.delete(m.key);
}
});
module.exports.addToCache = (key, value, callback) => {
if (!caches.has(key)) {
caches.set(key, []);
}
caches.get(key).push(value);
setImmediate(() => callback());
};
module.exports.getFromCache = (key, callback) => {
let value;
if (caches.has(key)) {
value = caches.get(key).shift();
if (!caches.get(key).length) {
caches.delete(key);
}
}
setImmediate(() => callback(null, value));
};
}

View file

@ -1,81 +0,0 @@
// DEPRECATED
'use strict';
let _ = require('../lib/translate')._;
let helpers = require('../lib/helpers');
let templates = require('../lib/models/templates');
let campaigns = require('../lib/models/campaigns');
module.exports = {
getResource,
getMergeTagsForResource
};
function getResource(type, id, callback) {
if (type === 'template') {
templates.get(id, (err, template) => {
if (err || !template) {
return callback(err && err.message || err || _('Could not find template with specified ID'));
}
getMergeTagsForResource(template, (err, mergeTags) => {
if (err) {
return callback(err.message || err);
}
template.mergeTags = mergeTags;
return callback(null, template);
});
});
} else if (type === 'campaign') {
campaigns.get(id, false, (err, campaign) => {
if (err || !campaign) {
return callback(err && err.message || err || _('Could not find campaign with specified ID'));
}
getMergeTagsForResource(campaign, (err, mergeTags) => {
if (err) {
return callback(err.message || err);
}
campaign.mergeTags = mergeTags;
return callback(null, campaign);
});
});
} else {
return callback(_('Invalid resource type'));
}
}
function getMergeTagsForResource(resource, callback) {
helpers.getDefaultMergeTags((err, defaultMergeTags) => {
if (err) {
return callback(err.message || err);
}
if (!Number(resource.list)) {
return callback(null, defaultMergeTags);
}
helpers.getListMergeTags(resource.list, (err, listMergeTags) => {
if (err) {
return callback(err.message || err);
}
if (resource.type !== 2) {
return callback(null, defaultMergeTags.concat(listMergeTags));
}
helpers.getRSSMergeTags((err, rssMergeTags) => {
if (err) {
return callback(err.message || err);
}
callback(null, defaultMergeTags.concat(listMergeTags, rssMergeTags));
});
});
});
}

View file

@ -1,308 +0,0 @@
'use strict';
const { nodeifyFunction } = require('./nodeify');
const getSettings = nodeifyFunction(require('../models/settings').get);
let log = require('npmlog');
let config = require('config');
let nodemailer = require('nodemailer');
let openpgpEncrypt = require('nodemailer-openpgp').openpgpEncrypt;
let tools = require('./tools');
let db = require('./db');
let Handlebars = require('handlebars');
let fs = require('fs');
let path = require('path');
let templates = new Map();
let htmlToText = require('html-to-text');
let aws = require('aws-sdk');
let objectHash = require('object-hash');
let mjml = require('mjml');
let _ = require('./translate')._;
let util = require('util');
Handlebars.registerHelper('translate', function (context, options) { // eslint-disable-line prefer-arrow-callback
if (typeof options === 'undefined' && context) {
options = context;
context = false;
}
let result = _(options.fn(this)); // eslint-disable-line no-invalid-this
if (Array.isArray(context)) {
result = util.format(result, ...context);
}
return new Handlebars.SafeString(result);
});
module.exports.transport = false;
module.exports.update = () => {
createMailer(() => false);
};
module.exports.getMailer = callback => {
if (!module.exports.transport) {
return createMailer(callback);
}
callback(null, module.exports.transport);
};
module.exports.sendMail = (mail, template, callback) => {
if (!callback && typeof template === 'function') {
callback = template;
template = false;
}
if (!module.exports.transport) {
return createMailer(err => {
if (err) {
return callback(err);
}
return module.exports.sendMail(mail, template, callback);
});
}
if (!mail.headers) {
mail.headers = {};
}
mail.headers['X-Sending-Zone'] = 'transactional';
getTemplate(template.html, (err, htmlRenderer) => {
if (err) {
return callback(err);
}
if (htmlRenderer) {
mail.html = htmlRenderer(template.data || {});
}
tools.prepareHtml(mail.html, (err, prepareHtml) => {
if (err) {
// ignore
}
if (prepareHtml) {
mail.html = prepareHtml;
}
getTemplate(template.text, (err, textRenderer) => {
if (err) {
return callback(err);
}
if (textRenderer) {
mail.text = textRenderer(template.data || {});
} else if (mail.html) {
mail.text = htmlToText.fromString(mail.html, {
wordwrap: 130
});
}
let tryCount = 0;
let trySend = () => {
tryCount++;
module.exports.transport.sendMail(mail, (err, info) => {
if (err) {
log.error('Mail', err);
if (err.responseCode && err.responseCode >= 400 && err.responseCode < 500 && tryCount <= 5) {
// temporary error, try again
log.verbose('Mail', 'Retrying after %s sec. ...', tryCount);
return setTimeout(trySend, tryCount * 1000);
}
return callback(err);
}
return callback(null, info);
});
};
setImmediate(trySend);
});
});
});
};
function getTemplate(template, callback) {
if (!template) {
return callback(null, false);
}
let key = (typeof template === 'object') ? objectHash(template) : template;
if (templates.has(key)) {
return callback(null, templates.get(key));
}
let done = (source, isMjml = false) => {
if (isMjml) {
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]);
}
source = compiled.html;
}
let renderer = Handlebars.compile(source);
templates.set(key, renderer);
callback(null, renderer);
};
if (typeof template === 'object') {
tools.mergeTemplateIntoLayout(template.template, template.layout, (err, source) => {
if (err) {
return callback(err);
}
let isMjml = template.type === 'mjml';
done(source, isMjml);
});
} else {
fs.readFile(path.join(__dirname, '..', 'views', template), 'utf-8', (err, source) => {
if (err) {
return callback(err);
}
done(source);
});
}
}
function createMailer(callback) {
// FIXME
module.exports.transport = {
on: () => {},
isIdle: () => true,
checkThrottling: next => next()
};
return callback(null, module.exports.transport);
getSettings(['smtpHostname', 'smtpPort', 'smtpEncryption', 'smtpUser', 'smtpPass', 'smtpLog', 'smtpDisableAuth', 'smtpMaxConnections', 'smtpMaxMessages', 'smtpSelfSigned', 'pgpPrivateKey', 'pgpPassphrase', 'smtpThrottling', 'mailTransport', 'sesKey', 'sesSecret', 'sesRegion'], (err, configItems) => {
if (err) {
return callback(err);
}
let oldListeners = [];
if (module.exports.transport) {
oldListeners = module.exports.transport.listeners('idle');
module.exports.transport.removeAllListeners('idle');
module.exports.transport.removeAllListeners('stream');
module.exports.transport.checkThrottling = null;
}
let sendingRate = Number(configItems.smtpThrottling) || 0;
if (sendingRate) {
// convert to messages/second
sendingRate = sendingRate / 3600;
}
let transportOptions;
let logfunc = function () {
let args = [].slice.call(arguments);
let level = args.shift();
args.shift();
args.unshift('Mail');
log[level](...args);
};
if (configItems.mailTransport === 'smtp' || !configItems.mailTransport) {
transportOptions = {
pool: true,
host: configItems.smtpHostname,
port: Number(configItems.smtpPort) || false,
secure: configItems.smtpEncryption === 'TLS',
ignoreTLS: configItems.smtpEncryption === 'NONE',
auth: configItems.smtpDisableAuth ? false : {
user: configItems.smtpUser,
pass: configItems.smtpPass
},
debug: !!configItems.smtpLog,
logger: !configItems.smtpLog ? false : {
debug: logfunc.bind(null, 'verbose'),
info: logfunc.bind(null, 'info'),
error: logfunc.bind(null, 'error')
},
maxConnections: Number(configItems.smtpMaxConnections),
maxMessages: Number(configItems.smtpMaxMessages),
tls: {
rejectUnauthorized: !configItems.smtpSelfSigned
}
};
} else if (configItems.mailTransport === 'ses') {
transportOptions = {
SES: new aws.SES({
apiVersion: '2010-12-01',
accessKeyId: configItems.sesKey,
secretAccessKey: configItems.sesSecret,
region: configItems.sesRegion
}),
debug: !!configItems.smtpLog,
logger: !configItems.smtpLog ? false : {
debug: logfunc.bind(null, 'verbose'),
info: logfunc.bind(null, 'info'),
error: logfunc.bind(null, 'error')
},
maxConnections: Number(configItems.smtpMaxConnections),
sendingRate,
tls: {
rejectUnauthorized: !configItems.smtpSelfSigned
}
};
} else {
return callback(new Error(_('Invalid mail transport')));
}
module.exports.transport = nodemailer.createTransport(transportOptions, config.nodemailer);
module.exports.transport.use('stream', openpgpEncrypt({
signingKey: configItems.pgpPrivateKey,
passphrase: configItems.pgpPassphrase
}));
if (oldListeners.length) {
log.info('Mail', 'Reattaching %s idle listeners', oldListeners.length);
oldListeners.forEach(listener => module.exports.transport.on('idle', listener));
}
if (configItems.mailTransport === 'smtp' || !configItems.mailTransport) {
let throttling = Number(configItems.smtpThrottling) || 0;
if (throttling) {
throttling = 1 / (throttling / (3600 * 1000));
}
let lastCheck = Date.now();
module.exports.transport.checkThrottling = function (next) {
if (!throttling) {
return next();
}
let nextCheck = Date.now();
let checkDiff = (nextCheck - lastCheck);
if (checkDiff < throttling) {
log.verbose('Mail', 'Throttling next message in %s sec.', (throttling - checkDiff) / 1000);
setTimeout(() => {
lastCheck = Date.now();
next();
}, throttling - checkDiff);
} else {
lastCheck = nextCheck;
next();
}
};
} else {
module.exports.transport.checkThrottling = next => next();
}
db.clearCache('sender', () => {
callback(null, module.exports.transport);
});
});
}
module.exports.getTemplate = getTemplate;

232
lib/mailers.js Normal file
View file

@ -0,0 +1,232 @@
'use strict';
const log = require('npmlog');
const config = require('config');
const Handlebars = require('handlebars');
const util = require('util');
const nodemailer = require('nodemailer');
const aws = require('aws-sdk');
const openpgpEncrypt = require('nodemailer-openpgp').openpgpEncrypt;
const sendConfigurations = require('../models/send-configurations');
const contextHelpers = require('./context-helpers');
const settings = require('../models/settings');
const tools = require('./tools');
const htmlToText = require('html-to-text');
const bluebird = require('bluebird');
const _ = require('./translate')._;
Handlebars.registerHelper('translate', function (context, options) { // eslint-disable-line prefer-arrow-callback
if (typeof options === 'undefined' && context) {
options = context;
context = false;
}
let result = _(options.fn(this)); // eslint-disable-line no-invalid-this
if (Array.isArray(context)) {
result = util.format(result, ...context);
}
return new Handlebars.SafeString(result);
});
const transports = new Map();
async function getOrCreateMailer(sendConfiguration) {
if (!sendConfiguration) {
sendConfiguration = sendConfigurations.getSystemSendConfiguration();
}
const transport = transports.get(sendConfiguration.id) || await _createTransport(sendConfiguration);
return transport.mailer;
}
function invalidateMailer(sendConfiguration) {
transports.delete(sendConfiguration.id);
}
async function _sendMail(transport, mail, template) {
if (!mail.headers) {
mail.headers = {};
}
mail.headers['X-Sending-Zone'] = 'transactional';
const htmlRenderer = await tools.getTemplate(template.html);
if (htmlRenderer) {
mail.html = htmlRenderer(template.data || {});
}
const preparedHtml = await tools.prepareHtml(mail.html);
if (prepareHtml) {
mail.html = prepareHtml;
}
const textRenderer = await tools.getTemplate(template.text);
if (textRenderer) {
mail.text = textRenderer(template.data || {});
} else if (mail.html) {
mail.text = htmlToText.fromString(mail.html, {
wordwrap: 130
});
}
let tryCount = 0;
const trySend = (callback) => {
tryCount++;
transport.sendMail(mail, (err, info) => {
if (err) {
log.error('Mail', err);
if (err.responseCode && err.responseCode >= 400 && err.responseCode < 500 && tryCount <= 5) {
// temporary error, try again
log.verbose('Mail', 'Retrying after %s sec. ...', tryCount);
return setTimeout(trySend, tryCount * 1000);
}
return callback(err);
}
return callback(null, info);
});
};
const trySendAsync = bluebird.promisify(trySend);
return await trySendAsync();
}
async function _createTransport(sendConfiguration) {
const mailerSettings = sendConfiguration.mailer_settings;
const mailerType = sendConfiguration.mailer_type;
const configItems = await settings.get(contextHelpers.getAdminContext(), ['pgpPrivateKey', 'pgpPassphrase']);
const existingTransport = transports.get(sendConfiguration.id);
let existingListeners = [];
if (existingTransport) {
existingListeners = existingTransport.listeners('idle');
existingTransport.removeAllListeners('idle');
existingTransport.removeAllListeners('stream');
existingTransport.checkThrottling = null;
}
const logFunc = (...args) => {
const level = args.shift();
args.shift();
args.unshift('Mail');
log[level](...args);
};
let transportOptions;
if (mailerType === sendConfigurations.MailerType.GENERIC_SMTP || mailerType === sendConfigurations.MailerType.ZONE_MTA) {
transportOptions = {
pool: true,
host: mailerSettings.hostname,
port: mailerSettings.port || false,
secure: mailerSettings.encryption === 'TLS',
ignoreTLS: mailerSettings.encryption === 'NONE',
auth: mailerSettings.useAuth ? {
user: mailerSettings.user,
pass: mailerSettings.password
} : false,
debug: mailerSettings.logTransactions,
logger: mailerSettings.logTransactions ? {
debug: logFunc.bind(null, 'verbose'),
info: logFunc.bind(null, 'info'),
error: logFunc.bind(null, 'error')
} : false,
maxConnections: mailerSettings.maxConnections,
maxMessages: mailerSettings.maxMessages,
tls: {
rejectUnauthorized: !mailerSettings.allowSelfSigned
}
};
} else if (mailerType === sendConfigurations.MailerType.AWS_SES) {
const sendingRate = mailerSettings.throttling / 3600; // convert to messages/second
transportOptions = {
SES: new aws.SES({
apiVersion: '2010-12-01',
accessKeyId: mailerSettings.key,
secretAccessKey: mailerSettings.secret,
region: mailerSettings.region
}),
debug: mailerSettings.logTransactions,
logger: mailerSettings.logTransactions ? {
debug: logFunc.bind(null, 'verbose'),
info: logFunc.bind(null, 'info'),
error: logFunc.bind(null, 'error')
} : false,
maxConnections: mailerSettings.maxConnections,
sendingRate
};
} else {
throw new Error('Invalid mail transport');
}
const transport = nodemailer.createTransport(transportOptions, config.nodemailer);
transport.use('stream', openpgpEncrypt({
signingKey: configItems.pgpPrivateKey,
passphrase: configItems.pgpPassphrase
}));
if (existingListeners.length) {
log.info('Mail', 'Reattaching %s idle listeners', existingListeners.length);
existingListeners.forEach(listener => transport.on('idle', listener));
}
let checkThrottling;
if (mailerType === sendConfigurations.MailerType.GENERIC_SMTP || mailerType === sendConfigurations.MailerType.ZONE_MTA) {
let throttling = mailerSettings.throttling;
if (throttling) {
throttling = 1 / (throttling / (3600 * 1000));
}
let lastCheck = Date.now();
checkThrottling = function (next) {
if (!throttling) {
return next();
}
let nextCheck = Date.now();
let checkDiff = (nextCheck - lastCheck);
if (checkDiff < throttling) {
log.verbose('Mail', 'Throttling next message in %s sec.', (throttling - checkDiff) / 1000);
setTimeout(() => {
lastCheck = Date.now();
next();
}, throttling - checkDiff);
} else {
lastCheck = nextCheck;
next();
}
};
} else {
checkThrottling = next => next();
}
transport.mailer = {
checkThrottling,
sendMail: async (mail, template) => await _sendMail(transport, mail, template)
};
transports.set(sendConfiguration.id, transport);
return transport;
}
module.exports = {
getOrCreateMailer,
invalidateMailer
};

View file

@ -1,86 +0,0 @@
'use strict';
let db = require('../db');
module.exports.get = (start, limit, search, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
search = '%' + search + '%';
connection.query('SELECT SQL_CALC_FOUND_ROWS `email` FROM blacklist WHERE `email` LIKE ? ORDER BY `email` LIMIT ? OFFSET ?', [search, limit, start], (err, rows) => {
if (err) {
return callback(err);
}
connection.query('SELECT FOUND_ROWS() AS total', (err, total) => {
connection.release();
if (err) {
return callback(err);
}
let emails = [];
rows.forEach(email => {
emails.push(email.email);
});
return callback(null, emails, total && total[0] && total[0].total);
});
});
});
};
module.exports.add = (email, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('INSERT IGNORE INTO `blacklist` (`email`) VALUES(?)', email, err => {
if (err) {
return callback(err);
}
connection.release();
return callback(null, null);
});
});
};
module.exports.delete = (email, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM `blacklist` WHERE `email`=?', email, err => {
if (err) {
return callback(err);
}
connection.release();
return callback(null, null);
});
});
};
module.exports.isblacklisted = (email, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT `email` FROM blacklist WHERE `email`=?', email, (err, rows) => {
if (err) {
return callback(err);
}
connection.release();
if (rows.length > 0) {
return callback(null, true);
} else {
return callback(null, false);
}
});
});
};

File diff suppressed because it is too large Load diff

View file

@ -1,91 +0,0 @@
'use strict';
let db = require('../db');
let shortid = require('shortid');
let helpers = require('../helpers');
let _ = require('../translate')._;
/*
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, listId, action, ip, JSON.stringify(data || {})], (err, result) => {
connection.release();
if (err) {
return callback(err);
}
if (!result || !result.affectedRows) {
return callback(new Error(_('Could not store confirmation data')));
}
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

@ -1,617 +0,0 @@
'use strict';
let db = require('../db');
let tools = require('../tools');
let slugify = require('slugify');
let lists = require('./lists');
let shortid = require('shortid');
let Handlebars = require('handlebars');
let _ = require('../translate')._;
let util = require('util');
let allowedKeys = ['name', 'key', 'default_value', 'group', 'group_template', 'visible'];
let allowedTypes;
module.exports.grouped = ['radio', 'checkbox', 'dropdown'];
module.exports.types = {
text: _('Text'),
website: _('Website'),
longtext: _('Multi-line text'),
gpg: _('GPG Public Key'),
number: _('Number'),
radio: _('Radio Buttons'),
checkbox: _('Checkboxes'),
dropdown: _('Drop Down'),
'date-us': _('Date (MM/DD/YYY)'),
'date-eur': _('Date (DD/MM/YYYY)'),
'birthday-us': _('Birthday (MM/DD)'),
'birthday-eur': _('Birthday (DD/MM)'),
json: _('JSON value for custom rendering'),
option: _('Option')
};
module.exports.allowedTypes = allowedTypes = Object.keys(module.exports.types);
module.exports.genericTypes = {
text: 'string',
website: 'string',
longtext: 'textarea',
gpg: 'textarea',
json: 'textarea',
number: 'number',
'date-us': 'date',
'date-eur': 'date',
'birthday-us': 'birthday',
'birthday-eur': 'birthday',
option: 'boolean'
};
module.exports.list = (listId, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM custom_fields WHERE list=? ORDER BY id';
connection.query(query, [listId], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let fieldList = rows && rows.map(row => tools.convertKeys(row)) || [];
let groups = new Map();
// remove grouped rows
for (let i = fieldList.length - 1; i >= 0; i--) {
let field = fieldList[i];
if (module.exports.grouped.indexOf(field.type) >= 0) {
if (!groups.has(field.id)) {
groups.set(field.id, []);
}
field.options = groups.get(field.id);
} else if (field.group && field.type === 'option') {
if (!groups.has(field.group)) {
groups.set(field.group, [field]);
} else {
groups.get(field.group).unshift(field);
}
fieldList.splice(i, 1);
}
}
return callback(null, fieldList);
});
});
};
module.exports.get = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing List ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM custom_fields WHERE id=? LIMIT 1';
connection.query(query, [id], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let field = rows && rows[0] && tools.convertKeys(rows[0]) || false;
field.isGroup = module.exports.grouped.indexOf(field.type) >= 0 || field.type === 'json';
return callback(null, field);
});
});
};
module.exports.create = (listId, field, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
field = tools.convertKeys(field);
if (field.type === 'option' && !field.group) {
return callback(new Error(_('Option field requires a group to be selected')));
}
if (field.type !== 'option') {
field.group = null;
}
field.defaultValue = (field.defaultValue || '').toString().trim() || null;
field.groupTemplate = (field.groupTemplate || '').toString().trim() || null;
addCustomField(listId, field.name, field.defaultValue, field.type, field.group, field.groupTemplate, field.visible, callback);
};
module.exports.update = (id, updates, callback) => {
updates = updates || {};
id = Number(id) || 0;
updates = tools.convertKeys(updates);
if (id < 1) {
return callback(new Error(_('Missing Field ID')));
}
if (!(updates.name || '').toString().trim()) {
return callback(new Error(_('Field Name must be set')));
}
if (updates.key) {
updates.key = slugify(updates.key, '_').toUpperCase();
}
updates.defaultValue = (updates.defaultValue || '').toString().trim() || null;
updates.groupTemplate = (updates.groupTemplate || '').toString().trim() || null;
updates.visible = updates.visible ? 1 : 0;
let name = (updates.name || '').toString().trim();
let keys = ['name'];
let values = [name];
Object.keys(updates).forEach(key => {
let value = typeof updates[key] === 'string' ? updates[key].trim() : updates[key];
key = tools.toDbKey(key);
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 custom_fields 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 = (fieldId, callback) => {
fieldId = Number(fieldId) || 0;
if (fieldId < 1) {
return callback(new Error(_('Missing Field ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM custom_fields WHERE id=? LIMIT 1';
connection.query(query, [fieldId], (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
if (!rows || !rows.length) {
connection.release();
return callback(new Error(_('Custom field not found')));
}
let field = tools.convertKeys(rows[0]);
if (field.column) {
connection.query('ALTER TABLE `subscription__' + field.list + '` DROP COLUMN `' + field.column + '`', err => {
if (err && err.code !== 'ER_CANT_DROP_FIELD_OR_KEY') {
connection.release();
return callback(err);
}
connection.query('DELETE FROM custom_fields WHERE id=? LIMIT 1', [fieldId], err => {
if (err) {
connection.release();
return callback(err);
}
connection.query('DELETE FROM segment_rules WHERE column=? LIMIT 1', [field.column], err => {
connection.release();
if (err) {
// ignore
}
return callback(null, true);
});
});
});
} else {
// delete all subfields in this group
let query = 'SELECT id FROM custom_fields WHERE `group`=?';
connection.query(query, [fieldId], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
rows = [];
}
let pos = 0;
let deleteNext = () => {
if (pos >= rows.length) {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM custom_fields WHERE id=? LIMIT 1', [fieldId], err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
return;
}
module.exports.delete(rows[pos++].id, deleteNext);
};
deleteNext();
});
}
});
});
};
function addCustomField(listId, name, defaultValue, type, group, groupTemplate, visible, callback) {
type = (type || '').toString().trim().toLowerCase();
group = Number(group) || null;
listId = Number(listId) || 0;
let column = null;
let key = slugify('merge ' + name, '_').toUpperCase();
if (allowedTypes.indexOf(type) < 0) {
return callback(new Error(util.format(_('Unknown column type %s'), type)));
}
if (!name) {
return callback(new Error(_('Missing column name')));
}
if (listId <= 0) {
return callback(new Error(_('Missing list ID')));
}
lists.get(listId, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(_('Provided List ID not found'));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
if (module.exports.grouped.indexOf(type) < 0) {
column = ('custom_' + slugify(name, '_') + '_' + shortid.generate()).toLowerCase().replace(/[^a-z0-9_]/g, '');
}
let query = 'INSERT INTO custom_fields (`list`, `name`, `key`,`default_value`, `type`, `group`, `group_template`, `column`, `visible`) VALUES(?,?,?,?,?,?,?,?,?)';
connection.query(query, [listId, name, key, defaultValue, type, group, groupTemplate, column, visible ? 1 : 0], (err, result) => {
if (err) {
connection.release();
return callback(err);
}
let fieldId = result && result.insertId;
let indexQuery;
switch (type) {
case 'text':
case 'website':
query = 'ALTER TABLE `subscription__' + listId + '` ADD COLUMN `' + column + '` VARCHAR(255) DEFAULT NULL';
indexQuery = 'CREATE INDEX ' + column + '_index ON `subscription__' + listId + '` (`column`);';
break;
case 'gpg':
case 'longtext':
case 'json':
query = 'ALTER TABLE `subscription__' + listId + '` ADD COLUMN `' + column + '` TEXT DEFAULT NULL';
break;
case 'number':
query = 'ALTER TABLE `subscription__' + listId + '` ADD COLUMN `' + column + '` INT(11) DEFAULT NULL';
indexQuery = 'CREATE INDEX ' + column + '_index ON `subscription__' + listId + '` (`column`);';
break;
case 'option':
query = 'ALTER TABLE `subscription__' + listId + '` ADD COLUMN `' + column + '` TINYINT(4) UNSIGNED NOT NULL DEFAULT \'0\'';
indexQuery = 'CREATE INDEX ' + column + '_index ON `subscription__' + listId + '` (`column`);';
break;
case 'date-us':
case 'date-eur':
case 'birthday-us':
case 'birthday-eur':
query = 'ALTER TABLE `subscription__' + listId + '` ADD COLUMN `' + column + '` DATETIME NULL DEFAULT NULL';
indexQuery = 'CREATE INDEX ' + column + '_index ON `subscription__' + listId + '` (`column`);';
break;
default:
connection.release();
return callback(null, fieldId, key);
}
connection.query(query, err => {
if (err) {
connection.query('DELETE FROM custom_fields WHERE id=? LIMIT 1', [fieldId], () => connection.release());
return callback(err);
}
if (!indexQuery) {
connection.release();
return callback(null, fieldId, key);
} else {
connection.query(query, err => {
if (err) {
// ignore index errors
}
connection.release();
return callback(null, fieldId, key);
});
}
});
});
});
});
}
module.exports.getRow = (fieldList, values, useDate, showAll, onlyExisting) => {
let valueList = {};
let row = [];
Object.keys(values || {}).forEach(key => {
let value = values[key];
key = tools.toDbKey(key);
if (key.indexOf('custom_') === 0) {
valueList[key] = value;
} else if (key.indexOf('group_g') === 0 && value.indexOf('custom_') === 0) {
valueList[tools.toDbKey(value)] = 1;
}
});
fieldList.filter(field => showAll || field.visible).forEach(field => {
if (onlyExisting && field.column && !valueList.hasOwnProperty(field.column)) {
// ignore missing values
return;
}
/* eslint-disable indent */
switch (field.type) {
case 'text':
case 'website':
case 'gpg':
case 'longtext':
{
let item = {
id: field.id,
type: field.type,
name: field.name,
column: field.column,
value: (valueList[field.column] || '').toString().trim(),
visible: !!field.visible,
mergeTag: field.key,
mergeValue: (valueList[field.column] || '').toString().trim() || field.defaultValue,
['type' + (field.type || '').toString().trim().replace(/(?:^|-)([a-z])/g, (m, c) => c.toUpperCase())]: true
};
row.push(item);
break;
}
case 'json':
{
let value;
let json = (valueList[field.column] || '').toString().trim();
try {
let parsed = JSON.parse(json);
if (Array.isArray(parsed)) {
parsed = {
values: parsed
};
}
value = json ? render(field.groupTemplate, parsed) : '';
} catch (E) {
value = E.message;
}
let item = {
id: field.id,
type: field.type,
name: field.name,
column: field.column,
value: (valueList[field.column] || '').toString().trim(),
visible: !!field.visible,
mergeTag: field.key,
mergeValue: value || field.defaultValue,
['type' + (field.type || '').toString().trim().replace(/(?:^|-)([a-z])/g, (m, c) => c.toUpperCase())]: true
};
row.push(item);
break;
}
case 'number':
{
let item = {
id: field.id,
type: field.type,
name: field.name,
column: field.column,
value: Number(valueList[field.column]) || 0,
visible: !!field.visible,
mergeTag: field.key,
mergeValue: (Number(valueList[field.column]) || Number(field.defaultValue) || 0).toString(),
['type' + (field.type || '').toString().trim().replace(/(?:^|-)([a-z])/g, (m, c) => c.toUpperCase())]: true
};
row.push(item);
break;
}
case 'dropdown':
case 'radio':
case 'checkbox':
{
let hasSelectedOption = (field.options || []).some(subField => subField.column && valueList[subField.column]);
let item = {
id: field.id,
type: field.type,
name: field.name,
visible: !!field.visible,
key: 'group-g' + field.id,
mergeTag: field.key,
mergeValue: field.defaultValue,
['type' + (field.type || '').toString().trim().replace(/(?:^|-)([a-z])/g, (m, c) => c.toUpperCase())]: true,
groupTemplate: field.groupTemplate,
options: (field.options || []).map(subField => {
if (onlyExisting && subField.column && !valueList.hasOwnProperty(subField.column)) {
if (hasSelectedOption && field.type !== 'checkbox') {
// Set all radio or dropdown options if a selection for the group is present
} else if (field.type === 'checkbox' && values['originGroupG' + field.id] === 'webform') {
// Set all checkbox options if origin is webform (subscribe, manage, or admin edit) #333
// Atomic updates via API call or CSV import still possible
} else {
// ignore missing values
return false;
}
}
return {
type: subField.type,
name: subField.name,
column: subField.column,
value: valueList[subField.column] ? 1 : 0,
visible: !!subField.visible,
mergeTag: subField.key,
mergeValue: valueList[subField.column] ? subField.name : subField.defaultValue
};
}).filter(subField => subField)
};
let subItems = item.options.filter(subField => (showAll || subField.visible) && subField.value).map(subField => subField.name);
item.value = field.groupTemplate ? render(field.groupTemplate, {
values: subItems
}) : subItems.join(', ');
item.mergeValue = item.value || field.defaultValue;
row.push(item);
break;
}
case 'date-eur':
case 'birthday-eur':
case 'date-us':
case 'birthday-us':
{
let isUs = /-us$/.test(field.type);
let isYear = field.type.indexOf('date-') === 0;
let value = valueList[field.column];
let day, month, year;
let formatted;
if (value && typeof value.getUTCFullYear === 'function') {
day = value.getUTCDate();
month = value.getUTCMonth() + 1;
year = value.getUTCFullYear();
} else {
value = (value || '').toString().trim();
// try international format first YYYY-MM-DD
let parts = value.match(/(\d{4})\D+(\d{2})(?:\D+(\d{2})\b)?/);
if (parts) {
year = Number(parts[1]) || 2000;
month = Number(parts[2]) || 0;
day = Number(parts[3]) || 0;
value = new Date(Date.UTC(year, month - 1, day));
} else {
parts = value.match(/(\d+)\D+(\d+)(?:\D+(\d+)\b)?/);
if (!parts) {
value = null;
} else {
day = Number(parts[isUs ? 2 : 1]) || 0;
month = Number(parts[isUs ? 1 : 2]) || 0;
year = Number(parts[3]) || 2000;
if (!day || !month) {
value = null;
} else {
value = new Date(Date.UTC(year, month - 1, day));
}
}
}
}
if (day && month) {
if (isUs) {
formatted = (month < 10 ? '0' : '') + month + '/' + (day < 10 ? '0' : '') + day;
} else {
formatted = (day < 10 ? '0' : '') + day + '/' + (month < 10 ? '0' : '') + month;
}
if (isYear) {
formatted += '/' + year;
}
} else {
formatted = null;
}
let item = {
id: field.id,
type: field.type,
name: field.name,
column: field.column,
value: useDate ? value : formatted,
visible: !!field.visible,
mergeTag: field.key,
mergeValue: (useDate ? value : formatted) || field.defaultValue,
['type' + (field.type || '').toString().trim().replace(/(?:^|-)([a-z])/g, (m, c) => c.toUpperCase())]: true
};
row.push(item);
break;
}
}
/* eslint-enable indent */
});
return row;
};
module.exports.getValues = (row, showAll) => {
let result = [];
row.filter(field => showAll || field.visible).forEach(field => {
if (field.column) {
result.push({
key: field.column,
value: field.value
});
} else if (field.options) {
field.options.filter(field => showAll || field.visible).forEach(subField => {
result.push({
key: subField.column,
value: subField.value
});
});
}
});
return result;
};
function render(template, options) {
let renderer = Handlebars.compile(template);
return renderer(options);
}

View file

@ -1,418 +0,0 @@
'use strict';
let db = require('../db');
let fs = require('fs');
let path = require('path');
let tools = require('../tools');
let mjml = require('mjml');
let _ = require('../translate')._;
let allowedKeys = [
'name',
'description',
'fields_shown_on_subscribe',
'fields_shown_on_manage',
'layout',
'form_input_style',
'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',
'web_manage',
'web_manage_address',
'web_updated_notice',
'web_unsubscribe',
'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',
'web_manual_unsubscribe_notice'
];
module.exports.list = (listId, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM custom_forms WHERE list=? ORDER BY id', [listId], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let formList = rows && rows.map(row => tools.convertKeys(row)) || [];
return callback(null, formList);
});
});
};
module.exports.get = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Form ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM custom_forms WHERE id=? LIMIT 1', [id], (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
let form = rows && rows[0] && tools.convertKeys(rows[0]) || false;
if (!form) {
connection.release();
return callback(new Error('Selected form not found'));
}
connection.query('SELECT * FROM custom_forms_data WHERE form=?', [id], (err, data_rows = []) => {
connection.release();
if (err) {
return callback(err);
}
data_rows.forEach(data_row => {
let modelKey = tools.fromDbKey(data_row.data_key);
form[modelKey] = data_row.data_value;
});
return callback(null, form);
});
});
});
};
module.exports.create = (listId, form, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing Form ID')));
}
form = tools.convertKeys(form);
form = setDefaultValues(form);
form.name = (form.name || '').toString().trim();
if (!form.name) {
return callback(new Error(_('Form Name must be set')));
}
let keys = ['list'];
let values = [listId];
Object.keys(form).forEach(key => {
let value = form[key].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);
}
let filtered = filterKeysAndValues(keys, values, 'exclude', ['mail_', 'web_']);
let query = 'INSERT INTO custom_forms (' + filtered.keys.join(', ') + ') VALUES (' + filtered.values.map(() => '?').join(',') + ')';
connection.query(query, filtered.values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let formId = result && result.insertId;
if (!formId) {
return callback(new Error('Invalid custom_forms insertId'));
}
let jobs = 1;
let error = null;
let done = err => {
jobs--;
error = err ? err : error; // One's enough
jobs === 0 && callback(error, formId);
};
filtered = filterKeysAndValues(keys, values, 'include', ['mail_', 'web_']);
filtered.keys.forEach((key, index) => {
jobs++;
db.getConnection((err, connection) => {
if (err) {
return done(err);
}
connection.query('INSERT INTO custom_forms_data (form, data_key, data_value) VALUES (?, ?, ?)', [formId, key, filtered.values[index]], err => {
connection.release();
if (err) {
return done(err);
}
return done(null);
});
});
});
done(null);
});
});
};
module.exports.update = (id, updates, callback) => {
updates = updates || {};
id = Number(id) || 0;
updates = tools.convertKeys(updates);
if (id < 1) {
return callback(new Error(_('Missing Form ID')));
}
if (!(updates.name || '').toString().trim()) {
return callback(new Error(_('Form Name must be set')));
}
let keys = [];
let values = [];
Object.keys(updates).forEach(key => {
let value = typeof updates[key] === 'string' ? updates[key].trim() : updates[key];
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);
}
let filtered = filterKeysAndValues(keys, values, 'exclude', ['mail_', 'web_']);
let query = 'UPDATE custom_forms SET ' + filtered.keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE id=? LIMIT 1';
connection.query(query, filtered.values.concat(id), (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let affectedRows = result && result.affectedRows;
let jobs = 1;
let error = null;
let done = err => {
jobs--;
error = err ? err : error; // One's enough
if (jobs === 0) {
if (error) {
return callback(error);
}
// Save then validate, as otherwise their work get's lost ...
err = testForMjmlErrors(keys, values);
if (err) {
return callback(err);
}
return callback(null, affectedRows);
}
};
filtered = filterKeysAndValues(keys, values, 'include', ['mail_', 'web_']);
filtered.keys.forEach((key, index) => {
jobs++;
db.getConnection((err, connection) => {
if (err) {
return done(err);
}
connection.query('UPDATE custom_forms_data SET data_value=? WHERE data_key=? AND form=?', [filtered.values[index], key, id], err => {
connection.release();
if (err) {
return done(err);
}
return done(null);
});
});
});
done(null);
});
});
};
module.exports.delete = (formId, callback) => {
formId = Number(formId) || 0;
if (formId < 1) {
return callback(new Error(_('Missing Form ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM custom_forms WHERE id=? LIMIT 1', [formId], (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
if (!rows || !rows.length) {
connection.release();
return callback(new Error(_('Custom form not found')));
}
connection.query('DELETE FROM custom_forms WHERE id=? LIMIT 1', [formId], err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
});
};
function setDefaultValues(form) {
let getContents = fileName => {
try {
let basePath = path.join(__dirname, '..', '..');
let template = fs.readFileSync(path.join(basePath, fileName), 'utf8');
return template.replace(/\{\{#translate\}\}(.*?)\{\{\/translate\}\}/g, (m, s) => _(s));
} catch (err) {
return false;
}
};
allowedKeys.forEach(key => {
let modelKey = tools.fromDbKey(key);
let base = 'views/subscription/' + key.replace(/_/g, '-');
if (key.startsWith('mail') || key.startsWith('web')) {
form[modelKey] = getContents(base + '.mjml.hbs') || getContents(base + '.hbs') || '';
}
});
form.layout = getContents('views/subscription/layout.mjml.hbs') || '';
form.formInputStyle = getContents('public/subscription/form-input-style.css') || '@import url(/subscription/form-input-style.css);';
return form;
}
function filterKeysAndValues(keysIn, valuesIn, method = 'include', prefixes = []) {
let values = [];
let prefixMatch = key => (
prefixes.some(prefix => key.startsWith(prefix))
);
let keys = keysIn.filter((key, index) => {
if ((method === 'include' && prefixMatch(key)) || (method === 'exclude' && !prefixMatch(key))) {
values.push(valuesIn[index]);
return true;
}
return false;
});
return {
keys,
values
};
}
function testForMjmlErrors(keys, values) {
let errors = [];
let testLayout = '<mjml><mj-body><mj-container>{{{body}}}</mj-container></mj-body></mjml>';
let hasMjmlError = (template, layout = testLayout) => {
let source = layout.replace(/\{\{\{body\}\}\}/g, template);
let compiled;
try {
compiled = mjml.mjml2html(source);
} catch (err) {
return err;
}
if (compiled.errors.length) {
return compiled.errors[0].message || compiled.errors[0];
}
return null;
};
keys.forEach((key, index) => {
if (key.startsWith('mail_') || key.startsWith('web_')) {
let template = values[index];
let err = hasMjmlError(template);
err && errors.push(key + ': ' + (err.message || err));
key === 'mail_confirm_html' && !template.includes('{{confirmUrl}}') && errors.push(key + ': Missing {{confirmUrl}}');
} else if (key === 'layout') {
let layout = values[index];
let err = hasMjmlError('', layout);
err && errors.push('layout: ' + (err.message || err));
!layout.includes('{{{body}}}') && errors.push('layout: {{{body}}} not found');
}
});
if (errors.length) {
errors.forEach((err, index) => {
errors[index] = (index + 1) + ') ' + err;
});
return 'Please fix these MJML errors:\n\n' + errors.join('\n');
}
return null;
}

View file

@ -1,364 +0,0 @@
'use strict';
let db = require('../db');
let shortid = require('shortid');
let util = require('util');
let _ = require('../translate')._;
let geoip = require('geoip-ultralight');
let campaigns = require('./campaigns');
let subscriptions = require('./subscriptions');
let lists = require('./lists');
let log = require('npmlog');
let urllib = require('url');
let he = require('he');
let ua_parser = require('device');
module.exports.resolve = (linkCid, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT id, url FROM links WHERE `cid`=? LIMIT 1';
connection.query(query, [linkCid], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (rows && rows.length) {
return callback(null, rows[0].id, rows[0].url);
}
return callback(null, false);
});
});
};
module.exports.countClick = (remoteIp, useragent, campaignCid, listCid, subscriptionCid, linkId, callback) => {
getSubscriptionData(campaignCid, listCid, subscriptionCid, (err, data) => {
if (err) {
return callback(err);
}
if (!data || data.campaign.clickTrackingDisabled) {
return callback(null, false);
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.beginTransaction(err => {
if (err) {
connection.release();
return callback(err);
}
let country = geoip.lookupCountry(remoteIp) || null;
let device = ua_parser(useragent, { unknownUserAgentDeviceType: 'desktop', emptyUserAgentDeviceType: 'desktop' });
let query = 'INSERT INTO `campaign_tracker__' + data.campaign.id + '` (`list`, `subscriber`, `link`, `ip`, `device_type`, `country`) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE `count`=`count`+1';
connection.query(query, [data.list.id, data.subscription.id, linkId, remoteIp, device.type, country], (err, result) => {
if (err && err.code !== 'ER_DUP_ENTRY') {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
if (err && err.code === 'ER_DUP_ENTRY' || result.affectedRows > 1) {
return connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
connection.release();
return callback(null, false);
});
}
let query = 'UPDATE `subscription__' + data.list.id + '` SET `latest_click`=NOW(), `latest_open`=NOW() WHERE id=?';
connection.query(query, [data.subscription.id], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
let query = 'UPDATE links SET clicks = clicks + 1 WHERE id=?';
connection.query(query, [linkId], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
let query = 'INSERT INTO `campaign_tracker__' + data.campaign.id + '` (`list`, `subscriber`, `link`, `ip`, `device_type`, `country`) VALUES (?,?,?,?,?,?)';
connection.query(query, [data.list.id, data.subscription.id, 0, remoteIp, device.type, country], err => {
if (err && err.code !== 'ER_DUP_ENTRY') {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
if (err && err.code === 'ER_DUP_ENTRY') {
return connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
connection.release();
return callback(null, false);
});
}
let query = 'UPDATE campaigns SET clicks = clicks + 1 WHERE id=?';
connection.query(query, [data.campaign.id], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
connection.release();
return callback(null, false);
});
});
// also count clicks as open events in case beacon image was blocked
module.exports.countOpen(remoteIp, useragent, campaignCid, listCid, subscriptionCid, () => false);
});
});
});
});
});
});
});
};
module.exports.countOpen = (remoteIp, useragent, campaignCid, listCid, subscriptionCid, callback) => {
getSubscriptionData(campaignCid, listCid, subscriptionCid, (err, data) => {
if (err) {
return callback(err);
}
if (!data || data.campaign.openTrackingDisabled) {
return callback(null, false);
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.beginTransaction(err => {
if (err) {
connection.release();
return callback(err);
}
let country = geoip.lookupCountry(remoteIp) || null;
let device = ua_parser(useragent, { unknownUserAgentDeviceType: 'desktop', emptyUserAgentDeviceType: 'desktop' });
let query = 'INSERT INTO `campaign_tracker__' + data.campaign.id + '` (`list`, `subscriber`, `link`, `ip`, `device_type`, `country`) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE `count`=`count`+1';
connection.query(query, [data.list.id, data.subscription.id, -1, remoteIp, device.type, country], (err, result) => {
if (err && err.code !== 'ER_DUP_ENTRY') {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
if (err && err.code === 'ER_DUP_ENTRY' || result.affectedRows > 1) {
return connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
connection.release();
return callback(null, false);
});
}
let query = 'UPDATE `subscription__' + data.list.id + '` SET `latest_open`=NOW() WHERE id=?';
connection.query(query, [data.subscription.id], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
let query = 'UPDATE campaigns SET opened = opened + 1 WHERE id=?';
connection.query(query, [data.campaign.id], err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
connection.commit(err => {
if (err) {
return connection.rollback(() => {
connection.release();
return callback(err);
});
}
connection.release();
return callback(null, false);
});
});
});
});
});
});
});
};
module.exports.add = (url, campaignId, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let cid = shortid.generate();
let query = 'INSERT INTO links (`cid`, `campaign`, `url`) VALUES (?,?,?)';
connection.query(query, [cid, campaignId, url], (err, result) => {
if (err && err.code !== 'ER_DUP_ENTRY') {
connection.release();
return callback(err);
}
if (!err && result && result.insertId) {
connection.release();
return callback(null, result.insertId, cid);
}
let query = 'SELECT id, cid FROM links WHERE `campaign`=? AND `url`=? LIMIT 1';
connection.query(query, [campaignId, url], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (rows && rows.length) {
return callback(null, rows[0].id, rows[0].cid);
}
return callback(null, false);
});
});
});
};
module.exports.updateLinks = (campaign, list, subscription, serviceUrl, message, callback) => {
if ((campaign.openTrackingDisabled && campaign.clickTrackingDisabled) || !message || !message.trim()) {
// tracking is disabled, do not modify the message
return setImmediate(() => callback(null, message));
}
// insert tracking image
if (!campaign.openTrackingDisabled) {
let inserted = false;
let imgUrl = urllib.resolve(serviceUrl, util.format('/links/%s/%s/%s', campaign.cid, list.cid, encodeURIComponent(subscription.cid)));
let img = '<img src="' + imgUrl + '" width="1" height="1" alt="mt">';
message = message.replace(/<\/body\b/i, match => {
inserted = true;
return img + match;
});
if (!inserted) {
message = message + img;
}
if (campaign.clickTrackingDisabled) {
return callback(null, message);
}
}
if (!campaign.clickTrackingDisabled) {
let re = /(<a[^>]* href\s*=[\s"']*)(http[^"'>\s]+)/gi;
let urls = new Set();
(message || '').replace(re, (match, prefix, url) => {
urls.add(url);
});
let map = new Map();
let vals = urls.values();
let replaceUrls = () => {
callback(null,
message.replace(re, (match, prefix, url) =>
prefix + (map.has(url) ? urllib.resolve(serviceUrl, util.format('/links/%s/%s/%s/%s', campaign.cid, list.cid, encodeURIComponent(subscription.cid), encodeURIComponent(map.get(url)))) : url)));
};
let storeNext = () => {
let urlItem = vals.next();
if (urlItem.done) {
return replaceUrls();
}
module.exports.add(he.decode(urlItem.value, {
isAttributeValue: true
}), campaign.id, (err, linkId, cid) => {
if (err) {
log.error('Link', err);
return storeNext();
}
map.set(urlItem.value, cid);
return storeNext();
});
};
storeNext();
}
};
function getSubscriptionData(campaignCid, listCid, subscriptionCid, callback) {
campaigns.getByCid(campaignCid, (err, campaign) => {
if (err) {
return callback(err);
}
if (!campaign) {
return callback(new Error(_('Campaign not found')));
}
lists.getByCid(listCid, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(new Error(_('List not found')));
}
subscriptions.get(list.id, subscriptionCid, (err, subscription) => {
if (err) {
return callback(err);
}
if (!subscription) {
return callback(new Error(_('Subscription not found')));
}
return callback(null, {
campaign,
list,
subscription
});
});
});
});
}

View file

@ -1,332 +0,0 @@
'use strict';
let db = require('../db');
let tools = require('../tools');
let shortid = require('shortid');
let segments = require('./segments');
let subscriptions = require('./subscriptions');
let _ = require('../translate')._;
let tableHelpers = require('../table-helpers');
const UnsubscriptionMode = require('../../shared/lists').UnsubscriptionMode;
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);
};
module.exports.filter = (request, parent, callback) => {
tableHelpers.filter('lists', ['*'], request, ['#', 'name', 'cid', 'subscribers', 'description'], ['name'], 'name ASC', null, callback);
};
module.exports.filterQuicklist = (request, callback) => {
tableHelpers.filter('lists', ['id', 'name', 'subscribers'], request, ['#', 'name', 'subscribers'], ['name'], 'name ASC', null, callback);
};
module.exports.quicklist = callback => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT id, name, subscribers FROM lists ORDER BY name LIMIT 1000', (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
let lists = (rows || []).map(tools.convertKeys);
connection.query('SELECT id, list, name FROM segments ORDER BY list, name LIMIT 1000', (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let segments = (rows || []).map(tools.convertKeys);
lists.forEach(list => {
list.segments = segments.filter(segment => segment.list === list.id);
});
return callback(null, lists);
});
});
});
};
module.exports.getListsWithEmail = (email, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT id, name FROM lists', (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let lists = (rows || []).map(tools.convertKeys);
const results = [];
lists.forEach((list, index, arr) => {
subscriptions.getByEmail(list.id, email, (err, sub) => {
if (err) {
return callback(err);
}
if (sub) {
results.push(list.id);
}
if (index === arr.length - 1) {
return callback(null, lists.filter(list => results.includes(list.id)));
}
});
});
});
});
};
module.exports.getByCid = (cid, callback) => {
resolveCid(cid, (err, id) => {
if (err) {
return callback(err);
}
if (!id) {
return callback(null, false);
}
module.exports.get(id, callback);
});
};
module.exports.get = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing List ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM lists WHERE id=?', [id], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
let list = tools.convertKeys(rows[0]);
segments.list(list.id, (err, segmentList) => {
if (err || !segmentList) {
segmentList = [];
}
list.segments = segmentList;
return callback(null, list);
});
});
});
};
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);
data.publicSubscribe = data.publicSubscribe ? 1 : 0;
let name = (data.name || '').toString().trim();
if (!data) {
return callback(new Error(_('List Name must be set')));
}
let keys = ['name'];
let values = [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);
}
});
let cid = shortid.generate();
keys.push('cid');
values.push(cid);
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'INSERT INTO lists (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')';
connection.query(query, values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let listId = result && result.insertId || false;
if (!listId) {
return callback(null, false);
}
createSubscriptionTable(listId, err => {
if (err) {
// FIXME: rollback
return callback(err);
}
return callback(null, listId);
});
});
});
};
module.exports.delete = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing List ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM lists WHERE id=? LIMIT 1', id, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let affected = result && result.affectedRows || 0;
removeSubscriptionTable(id, err => {
if (err) {
return callback(err);
}
return callback(null, affected);
});
});
});
};
function resolveCid(cid, callback) {
cid = (cid || '').toString().trim();
if (!cid) {
return callback(new Error(_('Missing List CID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT id FROM lists WHERE cid=?', [cid], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, rows && rows[0] && rows[0].id || false);
});
});
}
function createSubscriptionTable(id, callback) {
let query = 'CREATE TABLE `subscription__' + id + '` LIKE subscription';
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query(query, err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
}
function removeSubscriptionTable(id, callback) {
let query = 'DROP TABLE IF EXISTS `subscription__' + id + '`';
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query(query, err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
}

View file

@ -1,688 +0,0 @@
'use strict';
let tools = require('../tools');
let db = require('../db');
let fields = require('./fields');
let util = require('util');
let _ = require('../translate')._;
module.exports.defaultColumns = [{
column: 'email',
name: _('Email address'),
type: 'string'
}, {
column: 'opt_in_country',
name: _('Signup country'),
type: 'string'
}, {
column: 'created',
name: _('Sign up date'),
type: 'date'
}, {
column: 'latest_open',
name: _('Latest open'),
type: 'date'
}, {
column: 'latest_click',
name: _('Latest click'),
type: 'date'
}, {
column: 'first_name',
name: _('First name'),
type: 'string'
}, {
column: 'last_name',
name: _('Last name'),
type: 'string'
}];
module.exports.list = (listId, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM segments WHERE list=? ORDER BY name';
connection.query(query, [listId], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let segments = (rows || []).map(tools.convertKeys);
return callback(null, segments);
});
});
};
module.exports.get = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Segment ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM segments WHERE id=? LIMIT 1';
connection.query(query, [id], (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
if (!rows || !rows.length) {
connection.release();
return callback(new Error(_('Segment not found')));
}
let segment = tools.convertKeys(rows[0]);
let query = 'SELECT * FROM segment_rules WHERE segment=? ORDER BY id ASC';
connection.query(query, [id], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
fields.list(segment.list, (err, fieldList) => {
if (err || !fieldList) {
fieldList = [];
}
segment.columns = [].concat(module.exports.defaultColumns);
fieldList.forEach(field => {
if (fields.genericTypes[field.type] === 'textarea') {
return;
}
if (field.column) {
segment.columns.push({
column: field.column,
name: field.name,
type: fields.genericTypes[field.type] || 'string'
});
}
if (field.options) {
field.options.forEach(subField => {
if (subField.column) {
segment.columns.push({
column: subField.column,
name: field.name + ': ' + subField.name,
type: fields.genericTypes[subField.type] || 'string'
});
}
});
}
});
segment.rules = (rows || []).map(rule => {
rule = tools.convertKeys(rule);
if (rule.value) {
try {
rule.value = JSON.parse(rule.value);
} catch (E) {
// ignore
}
}
if (!rule.value) {
rule.value = {};
}
rule.columnType = segment.columns.filter(column => rule.column === column.column).pop() || {};
rule.name = rule.columnType.name || '';
switch (rule.columnType.type) {
case 'number':
case 'date':
case 'birthday':
if (rule.value.relativeRange) {
let startString = rule.value.startDirection ? util.format(_('%s days after today'), rule.value.start) : util.format(_('%s days before today'), rule.value.start);
let endString = rule.value.endDirection ? util.format(_('%s days after today'), rule.value.end) : util.format(_('%s days before today'), rule.value.end);
rule.formatted = (rule.value.start ? startString : _('today')) + ' … ' + (rule.value.end ? endString : _('today'));
} else if (rule.value.range) {
rule.formatted = (rule.value.start || '') + ' … ' + (rule.value.end || '');
} else {
rule.formatted = rule.value.value || '';
}
break;
case 'boolean':
rule.formatted = rule.value.value ? _('Selected') : _('Not selected');
break;
default:
rule.formatted = rule.value.value || '';
}
return rule;
});
return callback(null, segment);
});
});
});
});
};
module.exports.create = (listId, segment, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
segment = tools.convertKeys(segment);
segment.name = (segment.name || '').toString().trim();
segment.type = Number(segment.type) || 0;
if (!segment.name) {
return callback(new Error(_('Field Name must be set')));
}
if (segment.type <= 0) {
return callback(new Error(_('Invalid segment rule type')));
}
let keys = ['list', 'name', 'type'];
let values = [listId, segment.name, segment.type];
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'INSERT INTO segments (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')';
connection.query(query, values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, result && result.insertId || false);
});
});
};
module.exports.update = (id, updates, callback) => {
updates = updates || {};
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Segment ID')));
}
let segment = tools.convertKeys(updates);
segment.name = (segment.name || '').toString().trim();
segment.type = Number(segment.type) || 0;
if (!segment.name) {
return callback(new Error(_('Field Name must be set')));
}
if (segment.type <= 0) {
return callback(new Error(_('Invalid segment rule type')));
}
let keys = ['name', 'type'];
let values = [segment.name, segment.type];
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
values.push(id);
connection.query('UPDATE segments 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;
if (id < 1) {
return callback(new Error(_('Missing Segment ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM segments WHERE id=? LIMIT 1', [id], err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
};
module.exports.createRule = (segmentId, rule, callback) => {
segmentId = Number(segmentId) || 0;
if (segmentId < 1) {
return callback(new Error(_('Missing Segment ID')));
}
rule = tools.convertKeys(rule);
module.exports.get(segmentId, (err, segment) => {
if (err) {
return callback(err);
}
if (!segment) {
return callback(new Error(_('Selected segment not found')));
}
let column = segment.columns.filter(column => column.column === rule.column).pop();
if (!column) {
return callback(new Error(_('Invalid rule type')));
}
let value;
switch (column.type) {
case 'date':
case 'birthday':
case 'number':
if (column.type === 'date' && rule.range === 'relative') {
value = {
relativeRange: true,
start: Number(rule.startRelative) || 0,
startDirection: Number(rule.startDirection) ? 1 : 0,
end: Number(rule.endRelative) || 0,
endDirection: Number(rule.endDirection) ? 1 : 0
};
} else if (rule.range === 'yes') {
value = {
range: true,
start: rule.start,
end: rule.end
};
} else {
value = {
value: rule.value
};
}
break;
case 'boolean':
value = {
value: rule.value ? 1 : 0
};
break;
default:
value = {
value: rule.value
};
}
let keys = ['segment', 'column', 'value'];
let values = [segment.id, rule.column, JSON.stringify(value)];
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'INSERT INTO segment_rules (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')';
connection.query(query, values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, result && result.insertId || false);
});
});
});
};
module.exports.getRule = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Rule ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM segment_rules WHERE id=? LIMIT 1';
connection.query(query, [id], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(new Error(_('Specified rule not found')));
}
let rule = tools.convertKeys(rows[0]);
module.exports.get(rule.segment, (err, segment) => {
if (err) {
return callback(err);
}
if (!segment) {
return callback(new Error(_('Specified segment not found')));
}
if (rule.value) {
try {
rule.value = JSON.parse(rule.value);
} catch (E) {
// ignore
}
}
if (!rule.value) {
rule.value = {};
}
rule.columnType = segment.columns.filter(column => rule.column === column.column).pop() || {};
rule.name = rule.columnType.name || '';
switch (rule.columnType.type) {
case 'number':
case 'date':
case 'birthday':
if (rule.value.relativeRange) {
let startString = rule.value.startDirection ? util.format(_('%s days after today'), rule.value.start) : util.format(_('%s days before today'), rule.value.start);
let endString = rule.value.endDirection ? util.format(_('%s days after today'), rule.value.end) : util.format(_('%s days before today'), rule.value.end);
rule.formatted = (rule.value.start ? startString : _('today')) + ' … ' + (rule.value.end ? endString : _('today'));
} else if (rule.value.range) {
rule.formatted = (rule.value.start || '') + ' … ' + (rule.value.end || '');
} else {
rule.formatted = rule.value.value || '';
}
break;
case 'boolean':
rule.formatted = rule.value.value ? _('Selected') : _('Not selected');
break;
default:
rule.formatted = rule.value.value || '';
}
return callback(null, rule);
});
});
});
};
module.exports.updateRule = (id, rule, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Rule ID')));
}
rule = tools.convertKeys(rule);
module.exports.getRule(id, (err, existingRule) => {
if (err) {
return callback(err);
}
if (!existingRule) {
return callback(new Error(_('Selected rule not found')));
}
module.exports.get(existingRule.segment, (err, segment) => {
if (err) {
return callback(err);
}
if (!segment) {
return callback(new Error(_('Selected segment not found')));
}
let column = segment.columns.filter(column => column.column === existingRule.column).pop();
if (!column) {
return callback(new Error(_('Invalid rule type')));
}
let value;
switch (column.type) {
case 'date':
case 'birthday':
case 'number':
if (column.type === 'date' && rule.range === 'relative') {
value = {
relativeRange: true,
start: Number(rule.startRelative) || 0,
startDirection: Number(rule.startDirection) ? 1 : 0,
end: Number(rule.endRelative) || 0,
endDirection: Number(rule.endDirection) ? 1 : 0
};
} else if (rule.range === 'yes') {
value = {
range: true,
start: rule.start,
end: rule.end
};
} else {
value = {
value: rule.value
};
}
break;
case 'boolean':
value = {
value: rule.value ? 1 : 0
};
break;
default:
value = {
value: rule.value
};
}
let keys = ['value'];
let values = [JSON.stringify(value)];
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
values.push(id);
connection.query('UPDATE segment_rules 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.deleteRule = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Rule ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM segment_rules WHERE id=? LIMIT 1', [id], err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
};
module.exports.getQuery = (id, prefix, callback) => {
module.exports.get(id, (err, segment) => {
if (err) {
return callback(err);
}
if (!segment) {
return callback(new Error(_('Segment not found')));
}
prefix = prefix ? prefix + '.' : '';
let query = [];
let values = [];
let getRelativeDate = (days, direction) => {
let date = new Date(Date.now() + (direction ? 1 : -1) * days * 24 * 3600 * 1000);
return date.toISOString().substr(0, 10);
};
let getDate = (value, nextDay) => {
let parts = value.trim().split(/\D/);
let year = Number(parts.shift()) || 0;
let month = Number(parts.shift()) || 0;
let day = Number(parts.shift()) || 0;
if (!year || !month || !day) {
return false;
}
return new Date(Date.UTC(year, month - 1, day + (nextDay ? 1 : 0)));
};
segment.rules.forEach(rule => {
switch (rule.columnType.type) {
case 'string':
query.push(prefix + '`' + rule.columnType.column + '` LIKE ?');
values.push(rule.value.value);
break;
case 'boolean':
query.push(prefix + '`' + rule.columnType.column + '` = ?');
values.push(rule.value.value);
break;
case 'number':
if (rule.value.range) {
let ruleval = '';
if (rule.value.start) {
ruleval = prefix + '`' + rule.columnType.column + '` >= ?';
values.push(rule.value.start);
}
if (rule.value.end) {
ruleval = (ruleval ? '(' + ruleval + ' AND ' : '') + prefix + '`' + rule.columnType.column + '` < ?' + (ruleval ? ')' : '');
values.push(rule.value.end);
}
if (ruleval) {
query.push(ruleval);
}
} else {
query.push(prefix + '`' + rule.columnType.column + '` = ?');
values.push(rule.value.value);
}
break;
case 'birthday':
if (rule.value.range) {
let start = rule.value.start || '01-01';
let end = rule.value.end || '12-31';
query.push('(' + prefix + '`' + rule.columnType.column + '` >= ? AND ' + prefix + '`' + rule.columnType.column + '` < ?)');
values.push(getDate('2000-' + start));
values.push(getDate('2000-' + end, true));
} else {
query.push('(' + prefix + '`' + rule.columnType.column + '` >= ? AND ' + prefix + '`' + rule.columnType.column + '` < ?)');
values.push(getDate('2000-' + rule.value.value));
values.push(getDate('2000-' + rule.value.value, true));
}
break;
case 'date':
if (rule.value.relativeRange) {
query.push('(' + prefix + '`' + rule.columnType.column + '` >= ? AND ' + prefix + '`' + rule.columnType.column + '` < ?)');
// start
values.push(getDate(getRelativeDate(rule.value.start, rule.value.startDirection)));
// end
values.push(getDate(getRelativeDate(rule.value.end, rule.value.endDirection), true));
} else if (rule.value.range) {
let ruleval = '';
if (rule.value.start) {
ruleval = prefix + '`' + rule.columnType.column + '` >= ?';
values.push(getDate(rule.value.start));
}
if (rule.value.end) {
ruleval = (ruleval ? '(' + ruleval + ' AND ' : '') + prefix + '`' + rule.columnType.column + '` < ?' + (ruleval ? ')' : '');
values.push(getDate(rule.value.end, true));
}
if (ruleval) {
query.push(ruleval);
}
} else {
query.push('(' + prefix + '`' + rule.columnType.column + '` >= ? AND ' + prefix + '`' + rule.columnType.column + '` < ?)');
values.push(getDate(rule.value.value));
values.push(getDate(rule.value.value, true));
}
break;
}
});
return callback(null, {
where: query.join(segment.type === 1 ? ' AND ' : ' OR ') || '1',
values
});
});
};
module.exports.subscribers = (id, onlySubscribed, callback) => {
module.exports.get(id, (err, segment) => {
if (err) {
return callback(err);
}
if (!segment) {
return callback(new Error(_('Segment not found')));
}
module.exports.getQuery(id, false, (err, queryData) => {
if (err) {
return callback(err);
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query;
if (!onlySubscribed) {
query = 'SELECT COUNT(id) AS `count` FROM `subscription__' + segment.list + '` WHERE ' + queryData.where + ' LIMIT 1';
} else {
query = 'SELECT COUNT(id) AS `count` FROM `subscription__' + segment.list + '` WHERE `status`=1 ' + (queryData.where ? ' AND (' + queryData.where + ')' : '') + ' LIMIT 1';
}
connection.query(query, queryData.values, (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let count = rows && rows[0] && rows[0].count || 0;
return callback(null, count);
});
});
});
});
};

View file

@ -1,926 +0,0 @@
'use strict';
let db = require('../db');
let shortid = require('shortid');
let striptags = require('striptags');
let tools = require('../tools');
let helpers = require('../helpers');
let fields = require('./fields');
let segments = require('./segments');
let _ = require('../translate')._;
let tableHelpers = require('../table-helpers');
const Status = require('../../shared/lists').SubscriptionStatus;
module.exports.Status = Status;
module.exports.list = (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);
});
};
module.exports.listTestUsers = (listId, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error('Missing List ID'));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT id, cid, email, first_name, last_name FROM `subscription__' + listId + '` WHERE is_test=1 LIMIT 100', (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, []);
}
let subscribers = rows.map(subscriber => {
subscriber = tools.convertKeys(subscriber);
let fullName = [].concat(subscriber.firstName || []).concat(subscriber.lastName || []).join(' ');
if (fullName) {
subscriber.displayName = fullName + ' <' + subscriber.email + '>';
} else {
subscriber.displayName = subscriber.email;
}
return subscriber;
});
return callback(null, subscribers);
});
});
};
module.exports.filter = (listId, request, columns, segmentId, callback) => {
listId = Number(listId) || 0;
segmentId = Number(segmentId) || 0;
if (!listId) {
return callback(new Error(_('Missing List ID')));
}
if (segmentId) {
segments.getQuery(segmentId, false, (err, queryData) => {
if (err) {
return callback(err);
}
tableHelpers.filter('subscription__' + listId, ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback);
});
} else {
tableHelpers.filter('subscription__' + listId, ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', null, 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);
subscriptionData = tools.convertKeys(subscriptionData);
meta.email = meta.email || subscriptionData.email;
meta.cid = meta.cid || shortid.generate();
fields.list(listId, (err, fieldList) => {
if (err) {
return callback(err);
}
let insertKeys = ['email', 'cid', 'opt_in_ip', 'opt_in_country', 'imported'];
let insertValues = [meta.email, meta.cid, meta.optInIp || null, meta.optInCountry || null, meta.imported || null];
let keys = [];
let values = [];
let allowedKeys = ['first_name', 'last_name', 'tz', 'is_test'];
Object.keys(subscriptionData).forEach(key => {
let value = subscriptionData[key];
key = tools.toDbKey(key);
if (key === 'tz') {
value = (value || '').toString().toLowerCase().trim();
}
if (key === 'is_test') {
value = value ? '1' : '0';
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);
}
});
fields.getValues(fields.getRow(fieldList, subscriptionData, true, true, !!meta.partial), true).forEach(field => {
keys.push(field.key);
values.push(field.value);
});
values = values.map(v => typeof v === 'string' ? striptags(v) : v);
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.beginTransaction(err => {
if (err) {
connection.release();
return callback(err);
}
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 helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
let query;
let queryArgs;
let existing = rows && rows[0] || false;
let entryId = existing ? existing.id : false;
meta.cid = existing ? rows[0].cid : meta.cid;
// meta.status may be 'undefined' or '0' when adding a subscription via API call or CSV import. In both cases meta.partial is 'true'.
// This must either update an existing subscription without changing its status or insert a new subscription with status SUBSCRIBED.
meta.status = meta.status || (existing ? existing.status : Status.SUBSCRIBED);
let statusChange = !existing || existing.status !== meta.status;
let statusDirection;
if (existing && existing.status === Status.SUBSCRIBED && !meta.partial) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Email address already registered'))));
}
if (statusChange) {
keys.push('status', 'status_change');
values.push(meta.status, new Date());
statusDirection = !existing ? (meta.status === Status.SUBSCRIBED ? '+' : false) : (existing.status === Status.SUBSCRIBED ? '-' : '+');
}
if (!keys.length) {
// nothing to update
return connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, {
entryId,
cid: meta.cid,
inserted: !existing
});
});
}
if (!existing) {
// insert as new
keys = insertKeys.concat(keys);
queryArgs = values = insertValues.concat(values);
query = 'INSERT INTO `subscription__' + listId + '` (`' + keys.join('`, `') + '`) VALUES (' + keys.map(() => '?').join(',') + ')';
} else {
// update existing
queryArgs = values.concat(existing.id);
query = 'UPDATE `subscription__' + listId + '` SET ' + keys.map(key => '`' + key + '`=?') + ' WHERE id=? LIMIT 1';
}
connection.query(query, queryArgs, (err, result) => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
entryId = result.insertId || entryId;
if (statusChange && statusDirection) {
connection.query('UPDATE lists SET `subscribers`=`subscribers`' + statusDirection + '1 WHERE id=?', [listId], err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, {
entryId,
cid: meta.cid,
inserted: !existing
});
});
});
} else {
connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, {
entryId,
cid: meta.cid,
inserted: !existing
});
});
}
});
});
});
});
});
};
module.exports.get = (listId, cid, callback) => {
cid = (cid || '').toString().trim();
if (!cid) {
return callback(new Error(_('Missing Subbscription ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM `subscription__' + listId + '` WHERE cid=?', [cid], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
let subscription = tools.convertKeys(rows[0]);
// ensure list id in response
subscription.list = subscription.list || listId;
return callback(null, subscription);
});
});
};
module.exports.getById = (listId, id, callback) => {
id = Number(id) || 0;
if (!id) {
return callback(new Error(_('Missing Subbscription ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM `subscription__' + listId + '` WHERE id=?', [id], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
let subscription = tools.convertKeys(rows[0]);
// ensure list id in response
subscription.list = subscription.list || listId;
return callback(null, subscription);
});
});
};
module.exports.getByEmail = (listId, email, callback) => {
if (!email) {
return callback(new Error(_('Missing Subbscription email address')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM `subscription__' + listId + '` WHERE email=?', [email], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
let subscription = tools.convertKeys(rows[0]);
// ensure list id in response
subscription.list = subscription.list || listId;
return callback(null, subscription);
});
});
};
module.exports.getWithMergeTags = (listId, cid, callback) => {
module.exports.get(listId, cid, (err, subscription) => {
if (err) {
return callback(err);
}
if (!subscription) {
return callback(null, false);
}
fields.list(listId, (err, fieldList) => {
if (err || !fieldList) {
return fieldList = [];
}
subscription.mergeTags = {
EMAIL: subscription.email,
FIRST_NAME: subscription.firstName,
LAST_NAME: subscription.lastName,
FULL_NAME: [].concat(subscription.firstName || []).concat(subscription.lastName || []).join(' '),
TIMEZONE: subscription.tz || ''
};
fields.getRow(fieldList, subscription, false, true).forEach(field => {
if (field.mergeTag) {
subscription.mergeTags[field.mergeTag] = field.mergeValue || '';
}
if (field.options) {
field.options.forEach(subField => {
if (subField.mergeTag) {
subscription.mergeTags[subField.mergeTag] = subField.mergeValue || '';
}
});
}
});
return callback(null, subscription);
});
});
};
module.exports.update = (listId, cid, updates, allowEmail, callback) => {
updates = tools.convertKeys(updates);
listId = Number(listId) || 0;
cid = (cid || '').toString().trim();
let keys = [];
let values = [];
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
if (!cid) {
return callback(new Error(_('Missing Subscription ID')));
}
fields.list(listId, (err, fieldList) => {
if (err) {
return callback(err);
}
let allowedKeys = ['first_name', 'last_name', 'tz', 'is_test'];
if (allowEmail) {
allowedKeys.unshift('email');
}
Object.keys(updates).forEach(key => {
let value = updates[key];
key = tools.toDbKey(key);
if (key === 'tz') {
value = (value || '').toString().toLowerCase().trim();
}
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);
}
});
fields.getValues(fields.getRow(fieldList, updates, true, true, true), true).forEach(field => {
keys.push(field.key);
values.push(field.value);
});
if (!values.length) {
return callback(null, false);
}
values = values.map(v => typeof v === 'string' ? striptags(v) : v);
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
values.push(cid);
connection.query('UPDATE `subscription__' + listId + '` SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE `cid`=? LIMIT 1', values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, result && result.affectedRows || false);
});
});
});
};
module.exports.changeStatus = (listId, id, campaignId, status, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.beginTransaction(err => {
if (err) {
connection.release();
return callback(err);
}
connection.query('SELECT `status` FROM `subscription__' + listId + '` WHERE id=? LIMIT 1', [id], (err, rows) => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (!rows || !rows.length) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(null, false));
}
let oldStatus = rows[0].status;
let statusChange = oldStatus !== status;
let statusDirection;
if (!statusChange) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(null, true));
}
if (statusChange && oldStatus === Status.SUBSCRIBED || status === Status.SUBSCRIBED) {
statusDirection = status === Status.SUBSCRIBED ? '+' : '-';
}
connection.query('UPDATE `subscription__' + listId + '` SET `status`=?, `status_change`=NOW() WHERE id=? LIMIT 1', [status, id], err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (!statusDirection) {
return connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, true);
});
}
connection.query('UPDATE `lists` SET `subscribers`=`subscribers`' + statusDirection + '1 WHERE id=? LIMIT 1', [listId], err => {
if (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 !== Status.SUBSCRIBED && status !== Status.UNSUBSCRIBED) {
return connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, true);
});
}
connection.query('SELECT `id` FROM `campaigns` WHERE `cid`=? LIMIT 1', [campaignId], (err, rows) => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
let campaign = rows && rows[0] || false;
if (!campaign) {
// should not happend
return connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, true);
});
}
// we should see only unsubscribe events here but you never know
connection.query('UPDATE `campaigns` SET `unsubscribed`=`unsubscribed`' + (status === Status.UNSUBSCRIBED ? '+' : '-') + '1 WHERE `cid`=? LIMIT 1', [campaignId], err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
let query = 'UPDATE `campaign__' + campaign.id + '` SET `status`=? WHERE `list`=? AND `subscription`=? LIMIT 1';
let values = [status, listId, id];
// Updated tracker status
connection.query(query, values, 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(null, true);
});
});
});
});
});
});
});
});
});
};
module.exports.delete = (listId, cid, callback) => {
listId = Number(listId) || 0;
cid = (cid || '').toString().trim();
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
if (!cid) {
return callback(new Error(_('Missing subscription ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT id, email, status FROM `subscription__' + listId + '` WHERE cid=? LIMIT 1', [cid], (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
let subscription = rows && rows[0];
if (!subscription) {
connection.release();
return callback(null, false);
}
connection.beginTransaction(err => {
if (err) {
connection.release();
return callback(err);
}
connection.query('DELETE FROM `subscription__' + listId + '` WHERE cid=? LIMIT 1', [cid], err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (subscription.status !== Status.SUBSCRIBED) {
return connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, subscription.email);
});
}
connection.query('UPDATE lists SET subscribers=subscribers-1 WHERE id=? LIMIT 1', [listId], err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback(null, subscription.email);
});
});
});
});
});
});
};
module.exports.createImport = (listId, type, path, size, delimiter, emailcheck, mapping, callback) => {
listId = Number(listId) || 0;
type = Number(type) || 0;
if (listId < 1) {
return callback(new Error('Missing List ID'));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'INSERT INTO importer (`list`, `type`, `path`, `size`, `delimiter`, `emailcheck`, `mapping`) VALUES(?,?,?,?,?,?,?)';
connection.query(query, [listId, type, path, size, delimiter, emailcheck, JSON.stringify(mapping)], (err, result) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, result && result.insertId || false);
});
});
};
module.exports.updateImport = (listId, importId, data, callback) => {
listId = Number(listId) || 0;
importId = Number(importId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
if (importId < 1) {
return callback(new Error(_('Missing Import ID')));
}
let keys = [];
let values = [];
let allowedKeys = ['type', 'path', 'size', 'delimiter', 'status', 'error', 'processed', 'new', 'failed', 'mapping', 'finished'];
Object.keys(data).forEach(key => {
let value = data[key];
key = tools.toDbKey(key);
if (allowedKeys.indexOf(key) >= 0) {
keys.push(key);
values.push(value);
}
});
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'UPDATE importer SET ' + keys.map(key => '`' + key + '`=?') + ' WHERE id=? AND list=? LIMIT 1';
connection.query(query, values.concat([importId, listId]), (err, result) => {
if (err) {
connection.release();
return callback(err);
}
let affected = result && result.affectedRows || false;
if (data.failed === 0) {
// remove entries from import_failed table
let query = 'DELETE FROM `import_failed` WHERE `import`=?';
connection.query(query, [importId], () => {
connection.release();
return callback(null, affected);
});
} else {
connection.release();
return callback(null, affected);
}
});
});
};
module.exports.getImport = (listId, importId, callback) => {
listId = Number(listId) || 0;
importId = Number(importId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
if (importId < 1) {
return callback(new Error(_('Missing Import ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM importer WHERE id=? AND list=? LIMIT 1';
connection.query(query, [importId, listId], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
let importer = tools.convertKeys(rows[0]);
try {
importer.mapping = JSON.parse(importer.mapping);
} catch (E) {
importer.mapping = {
columns: []
};
}
return callback(null, importer);
});
});
};
module.exports.getFailedImports = (importId, callback) => {
importId = Number(importId) || 0;
if (importId < 1) {
return callback(new Error(_('Missing Import ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM import_failed WHERE import=? LIMIT 1000';
connection.query(query, [importId], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, (rows || []).map(tools.convertKeys));
});
});
};
module.exports.listImports = (listId, callback) => {
listId = Number(listId) || 0;
if (listId < 1) {
return callback(new Error(_('Missing List ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM importer WHERE list=? AND status > 0 ORDER BY id DESC';
connection.query(query, [listId], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, []);
}
let imports = rows.map(row => {
let importer = tools.convertKeys(row);
try {
importer.mapping = JSON.parse(importer.mapping);
} catch (E) {
importer.mapping = {
columns: []
};
}
return importer;
});
return callback(null, imports);
});
});
};
/*
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();
if (!list || !list.id) {
return callback(new Error(_('Missing List ID')));
}
if (!cid) {
return callback(new Error(_('Missing subscription ID')));
}
tools.validateEmail(emailNew, false, err => {
if (err) {
return callback(err);
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT * FROM `subscription__' + list.id + '` WHERE `cid`=? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1';
let args = [cid];
connection.query(query, args, (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
if (!rows || !rows.length) {
connection.release();
return callback(new Error(_('Unknown subscription ID')));
}
if (rows[0].email === emailNew) {
connection.release();
return callback(new Error(_('Nothing seems to be changed')));
}
let old = rows[0];
let query = 'SELECT `id` FROM `subscription__' + list.id + '` WHERE `email`=? AND `cid`<>? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1';
let args = [emailNew, cid];
connection.query(query, args, (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (rows && rows.length > 0) {
return callback(null, old, false);
} else {
return callback(null, old, true);
}
});
});
});
});
};
/*
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);
}
connection.beginTransaction(err => {
if (err) {
connection.release();
return callback(err);
}
let query = 'SELECT `id` FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1';
let args = [emailNew, subscriptionId];
connection.query(query, args, (err, rows) => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (rows && rows.length > 0) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Email address already registered'))));
}
let query = 'DELETE FROM `subscription__' + listId + '` WHERE `email`=? AND `id`<>?';
let args = [emailNew, subscriptionId];
connection.query(query, args, err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
let query = 'UPDATE `subscription__' + listId + '` SET `email`=? WHERE `id`=? AND `status`=' + Status.SUBSCRIBED + ' LIMIT 1';
let args = [emailNew, subscriptionId];
connection.query(query, args, (err, result) => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
if (!result || !result.affectedRows) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(new Error(_('Subscription not found in this list'))));
}
return connection.commit(err => {
if (err) {
return helpers.rollbackAndReleaseConnection(connection, () => callback(err));
}
connection.release();
return callback();
});
});
});
});
});
});
};
module.exports.getUnsubscriptionMode = (list, subscriptionId) => list.unsubscriptionMode; // eslint-disable-line no-unused-vars
// TODO: Once the unsubscription mode is customizable per segment, then this will be a good place to process it.

View file

@ -1,176 +0,0 @@
'use strict';
let db = require('../db');
let tools = require('../tools');
let _ = require('../translate')._;
let tableHelpers = require('../table-helpers');
let allowedKeys = ['description', 'editor_name', 'editor_data', 'html', 'text'];
module.exports.list = (start, limit, callback) => {
tableHelpers.list('templates', ['*'], 'name', null, start, limit, callback);
};
module.exports.filter = (request, parent, callback) => {
tableHelpers.filter('templates', ['*'], request, ['#', 'name', 'description'], ['name'], 'name ASC', null, callback);
};
module.exports.quicklist = callback => {
tableHelpers.quicklist('templates', ['id', 'name'], 'name', callback);
};
module.exports.get = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Template ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM templates WHERE id=?', [id], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
let template = tools.convertKeys(rows[0]);
return callback(null, template);
});
});
};
module.exports.create = (template, callback) => {
let data = tools.convertKeys(template);
if (!(data.name || '').toString().trim()) {
return callback(new Error(_('Template Name must be set')));
}
let name = (template.name || '').toString().trim();
let keys = ['name'];
let values = [name];
Object.keys(template).forEach(key => {
let value = template[key];
key = tools.toDbKey(key);
if (!allowedKeys.includes(key)) {
return;
}
value = value.trim();
if (key === 'description') {
value = tools.purifyHTML(value);
}
keys.push(key);
values.push(value);
});
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'INSERT INTO templates (' + keys.join(', ') + ') VALUES (' + values.map(() => '?').join(',') + ')';
connection.query(query, values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let templateId = result && result.insertId || false;
return callback(null, templateId);
});
});
};
module.exports.update = (id, updates, callback) => {
updates = updates || {};
id = Number(id) || 0;
let data = tools.convertKeys(updates);
if (id < 1) {
return callback(new Error(_('Missing Template ID')));
}
if (!(data.name || '').toString().trim()) {
return callback(new Error(_('Template Name must be set')));
}
let name = (updates.name || '').toString().trim();
let keys = ['name'];
let values = [name];
Object.keys(updates).forEach(key => {
let value = updates[key].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 templates 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.duplicate = (id, callback) => module.exports.get(id, (err, template) => {
if (err) {
return callback(err);
}
if (!template) {
return callback(new Error(_('Template does not exist')));
}
template.name = template.name + ' Copy';
return module.exports.create(template, callback);
});
module.exports.delete = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Template ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM templates WHERE id=? LIMIT 1', id, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let affected = result && result.affectedRows || 0;
return callback(null, affected);
});
});
};

View file

@ -1,384 +0,0 @@
'use strict';
let tools = require('../tools');
let db = require('../db');
let lists = require('./lists');
let util = require('util');
let _ = require('../translate')._;
let tableHelpers = require('../table-helpers');
module.exports.defaultColumns = [{
column: 'created',
name: _('Sign up date'),
type: 'date'
}, {
column: 'latest_open',
name: _('Latest open'),
type: 'date'
}, {
column: 'latest_click',
name: _('Latest click'),
type: 'date'
}];
module.exports.defaultCampaignEvents = [{
option: 'delivered',
name: _('Delivered')
}, {
option: 'opened',
name: _('Has Opened')
}, {
option: 'clicked',
name: _('Has Clicked')
}, {
option: 'not_opened',
name: _('Not Opened')
}, {
option: 'not_clicked',
name: _('Not Clicked')
}];
let defaultColumnMap = {};
let defaultEventMap = {};
module.exports.defaultColumns.forEach(col => defaultColumnMap[col.column] = col.name);
module.exports.defaultCampaignEvents.forEach(evt => defaultEventMap[evt.option] = evt.name);
module.exports.list = callback => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let tableFields = [
'`triggers`.`id` AS `id`',
'`triggers`.`name` AS `name`',
'`triggers`.`description` AS `description`',
'`triggers`.`enabled` AS `enabled`',
'`triggers`.`list` AS `list`',
'`lists`.`name` AS `list_name`',
'`source`.`id` AS `source_campaign`',
'`source`.`name` AS `source_campaign_name`',
'`dest`.`id` AS `dest_campaign`',
'`dest`.`name` AS `dest_campaign_name`',
'`triggers`.`count` AS `count`',
'`custom_fields`.`id` AS `column_id`',
'`triggers`.`column` AS `column`',
'`custom_fields`.`name` AS `column_name`',
'`triggers`.`rule` AS `rule`',
'`triggers`.`seconds` AS `seconds`',
'`triggers`.`created` AS `created`'
];
let query = 'SELECT ' + tableFields.join(', ') + ' FROM `triggers` LEFT JOIN `campaigns` `source` ON `source`.`id`=`triggers`.`source_campaign` LEFT JOIN `campaigns` `dest` ON `dest`.`id`=`triggers`.`dest_campaign` LEFT JOIN `lists` ON `lists`.`id`=`triggers`.`list` LEFT JOIN `custom_fields` ON `custom_fields`.`list` = `triggers`.`list` AND `custom_fields`.`column`=`triggers`.`column` ORDER BY `triggers`.`name`';
connection.query(query, (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
let triggers = (rows || []).map(tools.convertKeys).map(row => {
if (row.rule === 'subscription' && row.column && !row.columnName) {
row.columnName = defaultColumnMap[row.column];
}
let days = Math.round(row.seconds / (24 * 3600));
row.formatted = util.format('%s days after %s', days, row.rule === 'subscription' ? row.columnName : (util.format('%s <a href="/campaigns/view/%s">%s</a>', defaultEventMap[row.column], row.sourceCampaign, row.sourceCampaignName)));
return row;
});
return callback(null, triggers);
});
});
};
module.exports.getQuery = (id, callback) => {
module.exports.get(id, (err, trigger) => {
if (err) {
return callback(err);
}
let limit = 300;
// time..NOW..time + 24h, 24 hour window after trigger target to detect it
//We need a 24 hour window for triggers as the format for dates added via the API are stored as 00:00:00
let treshold = 3600 * 24;
let intervalQuery = (column, seconds, treshold) => column + ' <= NOW() - INTERVAL ' + seconds + ' SECOND AND ' + column + ' >= NOW() - INTERVAL ' + (treshold + seconds) + ' SECOND';
let query = false;
switch (trigger.rule) {
case 'subscription':
query = 'SELECT id FROM `subscription__' + trigger.list + '` subscription WHERE ' + intervalQuery('`' + trigger.column + '`', trigger.seconds, treshold) + ' AND id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'campaign':
switch (trigger.column) {
case 'delivered':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription WHERE campaign.status=1 AND ' + intervalQuery('`campaign`.`created`', trigger.seconds, treshold) + ' AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'not_clicked':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription LEFT JOIN `campaign_tracker__' + trigger.sourceCampaign + '` tracker ON tracker.list=campaign.list AND tracker.subscriber=subscription.id AND tracker.link=0 WHERE campaign.status=1 AND ' + intervalQuery('`campaign`.`created`', trigger.seconds, treshold) + ' AND tracker.created IS NULL AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'not_opened':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription LEFT JOIN `campaign_tracker__' + trigger.sourceCampaign + '` tracker ON tracker.list=campaign.list AND tracker.subscriber=subscription.id AND tracker.link=-1 WHERE campaign.status=1 AND ' + intervalQuery('`campaign`.`created`', trigger.seconds, treshold) + ' AND tracker.created IS NULL AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'clicked':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription LEFT JOIN `campaign_tracker__' + trigger.sourceCampaign + '` tracker ON tracker.list=campaign.list AND tracker.subscriber=subscription.id AND tracker.link=0 WHERE campaign.status=1 AND ' + intervalQuery('`tracker`.`created`', trigger.seconds, treshold) + ' AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
case 'opened':
query = 'SELECT subscription.id AS id FROM `subscription__' + trigger.list + '` subscription LEFT JOIN `campaign__' + trigger.sourceCampaign + '` campaign ON campaign.list=' + trigger.list + ' AND subscription.id=campaign.subscription LEFT JOIN `campaign_tracker__' + trigger.sourceCampaign + '` tracker ON tracker.list=campaign.list AND tracker.subscriber=subscription.id AND tracker.link=-1 WHERE campaign.status=1 AND ' + intervalQuery('`tracker`.`created`', trigger.seconds, treshold) + ' AND subscription.id NOT IN (SELECT subscription FROM `trigger__' + id + '` triggertable WHERE triggertable.`list` = ' + trigger.list + ' AND triggertable.`subscription` = subscription.`id`) LIMIT ' + limit;
break;
}
break;
}
callback(null, query);
});
};
module.exports.get = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error('Missing Trigger ID'));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT * FROM triggers WHERE id=?', [id], (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
if (!rows || !rows.length) {
return callback(null, false);
}
let trigger = tools.convertKeys(rows[0]);
return callback(null, trigger);
});
});
};
module.exports.create = (trigger, callback) => {
trigger = tools.convertKeys(trigger);
let name = (trigger.name || '').toString().trim();
let description = (trigger.description || '').toString().trim();
let listId = Number(trigger.list) || 0;
let seconds = (Number(trigger.days) || 0) * 24 * 3600;
let rule = (trigger.rule || '').toString().toLowerCase().trim();
let destCampaign = Number(trigger.destCampaign) || 0;
let sourceCampaign = null;
let column;
if (!listId) {
return callback(new Error(_('Missing or invalid list ID')));
}
if (seconds < 0) {
return callback(new Error(_('Days in the past are not allowed')));
}
if (!rule || ['campaign', 'subscription'].indexOf(rule) < 0) {
return callback(new Error(_('Missing or invalid trigger rule')));
}
switch (rule) {
case 'subscription':
column = (trigger.column || '').toString().toLowerCase().trim();
if (!column) {
return callback(new Error(_('Invalid subscription configuration')));
}
break;
case 'campaign':
column = (trigger.campaignOption || '').toString().toLowerCase().trim();
sourceCampaign = Number(trigger.sourceCampaign) || 0;
if (!column || !sourceCampaign) {
return callback(new Error(_('Invalid campaign configuration')));
}
if (sourceCampaign === destCampaign) {
return callback(new Error(_('A campaing can not be a target for itself')));
}
break;
default:
return callback(new Error(_('Missing or invalid trigger rule')));
}
lists.get(listId, (err, list) => {
if (err) {
return callback(err);
}
if (!list) {
return callback(new Error(_('Missing or invalid list ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let keys = ['name', 'description', 'list', 'source_campaign', 'rule', 'column', 'seconds', 'dest_campaign', 'last_check'];
let values = [name, description, list.id, sourceCampaign, rule, column, seconds, destCampaign];
let query = 'INSERT INTO `triggers` (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(', ') + ', NOW())';
connection.query(query, values, (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let id = result && result.insertId;
if (!id) {
return callback(new Error(_('Could not store trigger row')));
}
createTriggerTable(id, err => {
if (err) {
return callback(err);
}
return callback(null, id);
});
});
});
});
};
module.exports.update = (id, trigger, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing or invalid Trigger ID')));
}
trigger = tools.convertKeys(trigger);
let name = (trigger.name || '').toString().trim();
let description = (trigger.description || '').toString().trim();
let enabled = trigger.enabled ? 1 : 0;
let seconds = (Number(trigger.days) || 0) * 24 * 3600;
let rule = (trigger.rule || '').toString().toLowerCase().trim();
let destCampaign = Number(trigger.destCampaign) || 0;
let sourceCampaign = null;
let column;
if (seconds < 0) {
return callback(new Error(_('Days in the past are not allowed')));
}
if (!rule || ['campaign', 'subscription'].indexOf(rule) < 0) {
return callback(new Error(_('Missing or invalid trigger rule')));
}
switch (rule) {
case 'subscription':
column = (trigger.column || '').toString().toLowerCase().trim();
if (!column) {
return callback(new Error(_('Invalid subscription configuration')));
}
break;
case 'campaign':
column = (trigger.campaignOption || '').toString().toLowerCase().trim();
sourceCampaign = Number(trigger.sourceCampaign) || 0;
if (!column || !sourceCampaign) {
return callback(new Error(_('Invalid campaign configuration')));
}
if (sourceCampaign === destCampaign) {
return callback(new Error(_('A campaing can not be a target for itself')));
}
break;
default:
return callback(new Error(_('Missing or invalid trigger rule')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let keys = ['name', 'description', 'enabled', 'source_campaign', 'rule', 'column', 'seconds', 'dest_campaign'];
let values = [name, description, enabled, sourceCampaign, rule, column, seconds, destCampaign];
let query = 'UPDATE `triggers` SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE `id`=? LIMIT 1';
connection.query(query, values.concat(id), (err, result) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, result && result.affectedRows);
});
});
};
module.exports.delete = (id, callback) => {
id = Number(id) || 0;
if (id < 1) {
return callback(new Error(_('Missing Trigger ID')));
}
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('DELETE FROM triggers WHERE id=? LIMIT 1', [id], (err, result) => {
connection.release();
if (err) {
return callback(err);
}
let affected = result && result.affectedRows || 0;
removeTriggerTable(id, err => {
if (err) {
return callback(err);
}
return callback(null, affected);
});
});
});
};
module.exports.filterSubscribers = (trigger, request, columns, callback) => {
let queryData = {
where: 'trigger__' + trigger.id + '.list=?',
values: [trigger.list]
};
tableHelpers.filter('subscription__' + trigger.list + ' JOIN trigger__' + trigger.id + ' ON trigger__' + trigger.id + '.subscription=subscription__' + trigger.list + '.id', ['*'], request, columns, ['email', 'first_name', 'last_name'], 'email ASC', queryData, callback);
};
function createTriggerTable(id, callback) {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'CREATE TABLE `trigger__' + id + '` LIKE `trigger`';
connection.query(query, err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
}
function removeTriggerTable(id, callback) {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'DROP TABLE IF EXISTS `trigger__' + id + '`';
connection.query(query, err => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, true);
});
});
}

View file

@ -8,7 +8,7 @@ const contextHelpers = require('../lib/context-helpers');
let runningWorkersCount = 0;
let maxWorkersCount = 1;
let workers = {};
const workers = {};
function startWorker(report) {

View file

@ -3,16 +3,13 @@
const log = require('npmlog');
const fields = require('../models/fields');
const settings = require('../models/settings');
const urllib = require('url');
const helpers = require('./helpers');
const {getTrustedUrl} = require('./urls');
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);
const mailers = require('./mailers');
module.exports = {
sendAlreadySubscribed,
@ -99,8 +96,6 @@ function getDisplayName(flds, subscription) {
}
async function _sendMail(list, email, template, subject, relativeUrls, subscription) {
console.log(subscription);
const flds = await fields.list(contextHelpers.getAdminContext(), list.id);
const encryptionKeys = [];
@ -110,16 +105,16 @@ async function _sendMail(list, email, template, subject, relativeUrls, subscript
}
}
const configItems = await settings.get(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'serviceUrl']);
const configItems = await settings.get(contextHelpers.getAdminContext(), ['defaultHomepage', 'adminEmail']);
const data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
contactAddress: configItems.defaultAddress,
homepage: configItems.defaultHomepage || getTrustedUrl(),
contactAddress: list.from_email || configItems.adminEmail,
};
for (let relativeUrlKey in relativeUrls) {
data[relativeUrlKey] = urllib.resolve(configItems.serviceUrl, relativeUrls[relativeUrlKey]);
data[relativeUrlKey] = getTrustedUrl(relativeUrls[relativeUrlKey]);
}
const fsTemplate = template.replace(/_/g, '-');
@ -142,22 +137,27 @@ async function _sendMail(list, email, template, subject, relativeUrls, subscript
}
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
});
if (list.send_configuration) {
const mailer = await mailers.getOrCreateMailer(list.send_configuration);
await mailer.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
});
} else {
log.warn('Subscription', `Not sending email for list id:${list.id} because not send configuration is set.`);
}
} catch (err) {
log.error('Subscription', err);
}

View file

@ -1,134 +0,0 @@
'use strict';
const db = require('./db');
const tools = require('./tools');
module.exports.list = (source, fields, orderBy, queryData, start, limit, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let limitQuery = '';
let limitValues = [];
if (limit) {
limitQuery = ' LIMIT ?';
limitValues.push(limit);
if (start) {
limitQuery += ' OFFSET ?';
limitValues.push(start);
}
}
let whereClause = '';
let whereValues = [];
if (queryData) {
whereClause = ' WHERE ' + queryData.where;
whereValues = queryData.values;
}
connection.query('SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source + whereClause + ' ORDER BY ' + orderBy + ' DESC' + limitQuery, whereValues.concat(limitValues), (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
connection.query('SELECT FOUND_ROWS() AS total', (err, total) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, rows, total && total[0] && total[0].total);
});
});
});
};
module.exports.quicklist = (source, fields, orderBy, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
connection.query('SELECT ' + fields.join(', ') + ' FROM ' + source + ' ORDER BY ' + orderBy + ' LIMIT 1000', (err, rows) => {
connection.release();
if (err) {
return callback(err);
}
return callback(null, (rows || []).map(tools.convertKeys));
});
});
};
module.exports.filter = (source, fields, request, columns, searchFields, defaultOrdering, queryData, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}
let query = 'SELECT COUNT(*) AS total FROM ' + source;
let values = [];
if (queryData) {
query += ' WHERE ' + queryData.where;
values = values.concat(queryData.values || []);
}
connection.query(query, values, (err, total) => {
if (err) {
connection.release();
return callback(err);
}
total = total && total[0] && total[0].total || 0;
let ordering = [];
if (request.order && request.order.length) {
request.order.forEach(order => {
let orderField = columns[Number(order.column)];
let orderDirection = (order.dir || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC';
if (orderField) {
ordering.push(orderField + ' ' + orderDirection);
}
});
}
if (!ordering.length) {
ordering.push(defaultOrdering);
}
let searchWhere = '';
let searchArgs = [];
if (request.search && request.search.value) {
let searchVal = '%' + request.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%';
searchWhere = searchFields.map(field => field + ' LIKE ?').join(' OR ');
searchArgs = searchFields.map(() => searchVal);
}
let query = 'SELECT SQL_CALC_FOUND_ROWS ' + fields.join(', ') + ' FROM ' + source +' WHERE ' + (searchWhere ? '(' + searchWhere + ')': '1') + (queryData ? ' AND (' + queryData.where + ')' : '') + ' ORDER BY ' + ordering.join(', ') + ' LIMIT ? OFFSET ?';
let args = searchArgs.concat(queryData ? queryData.values : []).concat([Number(request.length) || 50, Number(request.start) || 0]);
connection.query(query, args, (err, rows) => {
if (err) {
connection.release();
return callback(err);
}
connection.query('SELECT FOUND_ROWS() AS total', (err, filteredTotal) => {
connection.release();
if (err) {
return callback(err);
}
rows = rows.map(row => tools.convertKeys(row));
filteredTotal = filteredTotal && filteredTotal[0] && filteredTotal[0].total || 0;
return callback(null, rows, total, filteredTotal);
});
});
});
});
};

View file

@ -1,50 +0,0 @@
'use strict';
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,
validateEmailGetMessage,
mergeTemplateIntoLayout
};
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;
}
}

View file

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

View file

@ -1,23 +1,23 @@
'use strict';
const config = require('config');
const url = require('url');
const urllib = require('url');
function getTrustedUrl(path) {
return config.www.trustedUrlBase + (path || '');
return urllib.resolve(config.www.trustedUrlBase, path || '');
}
function getSandboxUrl(path) {
return config.www.sandboxUrlBase + (path || '');
return urllib.resolve(config.www.sandboxUrlBase, path || '');
}
function getTrustedUrlBaseDir() {
const mailtrainUrl = url.parse(getTrustedUrl());
const mailtrainUrl = urllib.parse(getTrustedUrl());
return mailtrainUrl.pathname;
}
function getSandboxUrlBaseDir() {
const mailtrainUrl = url.parse(getSandboxUrl());
const mailtrainUrl = urllib.parse(getSandboxUrl());
return mailtrainUrl.pathname;
}