Skip to content

Commit

Permalink
Full support for message attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
andris9 committed Sep 9, 2016
1 parent bfc6983 commit 35bce32
Show file tree
Hide file tree
Showing 6 changed files with 232 additions and 109 deletions.
11 changes: 7 additions & 4 deletions lib/models/campaigns.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,10 @@ module.exports.getAttachments = (campaign, callback) => {
if (err) {
return callback(err);
}
connection.query('SELECT `id`, `filename`, `size`, `created` FROM `attachments` WHERE `campaign`=?', [campaign], (err, rows) => {

let keys = ['id', 'filename', 'content_type', 'size', 'created'];

connection.query('SELECT `' + keys.join('`, `') + '` FROM `attachments` WHERE `campaign`=?', [campaign], (err, rows) => {
connection.release();
if (err) {
return callback(err);
Expand Down Expand Up @@ -455,14 +458,14 @@ module.exports.deleteAttachment = (id, attachment, callback) => {
});
};

module.exports.getAttachment = (id, attachment, callback) => {
module.exports.getAttachment = (campaign, attachment, callback) => {
db.getConnection((err, connection) => {
if (err) {
return callback(err);
}

let query = 'SELECT `filename`, `content_type`, `content` FROM `attachments` WHERE `id`=? AND `campaign`=? LIMIT 1';
connection.query(query, [attachment, id], (err, rows) => {
let query = 'SELECT * FROM `attachments` WHERE `id`=? AND `campaign`=? LIMIT 1';
connection.query(query, [attachment, campaign], (err, rows) => {
connection.release();
if (err) {
return callback(err);
Expand Down
117 changes: 75 additions & 42 deletions routes/archive.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ let tools = require('../lib/tools');
let express = require('express');
let request = require('request');
let router = new express.Router();
let passport = require('../lib/passport');

router.get('/:campaign/:list/:subscription', (req, res, next) => {
router.get('/:campaign/:list/:subscription', passport.csrfProtection, (req, res, next) => {
settings.get('serviceUrl', (err, serviceUrl) => {
if (err) {
req.flash('danger', err.message || err);
Expand Down Expand Up @@ -53,54 +54,86 @@ router.get('/:campaign/:list/:subscription', (req, res, next) => {
return next(err);
}

let renderHtml = (html, renderTags) => {
res.render('archive/view', {
layout: 'archive/layout',
message: renderTags ? tools.formatMessage(serviceUrl, campaign, list, subscription, html) : html,
campaign,
list,
subscription
});
};

let renderAndShow = (html, renderTags) => {
if (req.query.track === 'no') {
return renderHtml(html, renderTags);
campaigns.getAttachments(campaign.id, (err, attachments) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
// rewrite links to count clicks
links.updateLinks(campaign, list, subscription, serviceUrl, html, (err, html) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
renderHtml(html, renderTags);
});
};

if (campaign.sourceUrl) {
let form = tools.getMessageLinks(serviceUrl, campaign, list, subscription);
Object.keys(subscription.mergeTags).forEach(key => {
form[key] = subscription.mergeTags[key];
});
request.post({
url: campaign.sourceUrl,
form
}, (err, httpResponse, body) => {
if (err) {
return next(err);
}
if (httpResponse.statusCode !== 200) {
return next(new Error('Received status code ' + httpResponse.statusCode + ' from ' + campaign.sourceUrl));
let renderHtml = (html, renderTags) => {
res.render('archive/view', {
layout: 'archive/layout',
message: renderTags ? tools.formatMessage(serviceUrl, campaign, list, subscription, html) : html,
campaign,
list,
subscription,
attachments,
csrfToken: req.csrfToken()
});
};

let renderAndShow = (html, renderTags) => {
if (req.query.track === 'no') {
return renderHtml(html, renderTags);
}
renderAndShow(body && body.toString(), false);
});
} else {
renderAndShow(campaign.html, true);
}
// rewrite links to count clicks
links.updateLinks(campaign, list, subscription, serviceUrl, html, (err, html) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
renderHtml(html, renderTags);
});
};

if (campaign.sourceUrl) {
let form = tools.getMessageLinks(serviceUrl, campaign, list, subscription);
Object.keys(subscription.mergeTags).forEach(key => {
form[key] = subscription.mergeTags[key];
});
request.post({
url: campaign.sourceUrl,
form
}, (err, httpResponse, body) => {
if (err) {
return next(err);
}
if (httpResponse.statusCode !== 200) {
return next(new Error('Received status code ' + httpResponse.statusCode + ' from ' + campaign.sourceUrl));
}
renderAndShow(body && body.toString(), false);
});
} else {
renderAndShow(campaign.html, true);
}
});
});
});
});
});
});

router.post('/attachment/download', passport.parseForm, passport.csrfProtection, (req, res) => {
let url = '/archive/' + encodeURIComponent(req.body.campaign || '') + '/' + encodeURIComponent(req.body.list || '') + '/' + encodeURIComponent(req.body.subscription || '');
campaigns.getByCid(req.body.campaign, (err, campaign) => {
if (err || !campaign) {
req.flash('danger', err && err.message || err || 'Could not find campaign with specified ID');
return res.redirect(url);
}
campaigns.getAttachment(campaign.id, Number(req.body.attachment), (err, attachment) => {
if (err) {
req.flash('danger', err && err.message || err);
return res.redirect(url);
} else if (!attachment) {
req.flash('warning', 'Attachment not found');
return res.redirect(url);
}

res.set('Content-Disposition', 'attachment; filename="' + encodeURIComponent(attachment.filename).replace(/['()]/g, escape) + '"');
res.set('Content-Type', attachment.contentType);
res.send(attachment.content);
});
});
});

module.exports = router;
2 changes: 1 addition & 1 deletion routes/campaigns.js
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,7 @@ router.post('/attachment/download', passport.parseForm, passport.csrfProtection,
req.flash('danger', err && err.message || err);
return res.redirect('/campaigns/edit/' + campaign.id + '?tab=attachments');
} else if (!attachment) {
req.flash('success', 'Attachment uploaded');
req.flash('warning', 'Attachment not found');
return res.redirect('/campaigns/edit/' + campaign.id + '?tab=attachments');
}

Expand Down
172 changes: 114 additions & 58 deletions services/sender.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ let request = require('request');
let caches = require('../lib/caches');
let libmime = require('libmime');

let attachmentCache = new Map();
let attachmentCacheSize = 0;

function findUnsent(callback) {
let returnUnsent = (row, campaign) => {
db.getConnection((err, connection) => {
Expand Down Expand Up @@ -195,6 +198,54 @@ function findUnsent(callback) {
});
}

function getAttachments(campaign, callback) {
campaigns.getAttachments(campaign.id, (err, attachments) => {
if (err) {
return callback(err);
}
if (!attachments) {
return callback(null, []);
}

let response = [];
let pos = 0;
let getNextAttachment = () => {
if (pos >= attachments.length) {
return callback(null, response);
}
let attachment = attachments[pos++];
let aid = campaign.id + ':' + attachment.id;
if (attachmentCache.has(aid)) {
response.push(attachmentCache.get(aid));
return setImmediate(getNextAttachment);
}
campaigns.getAttachment(campaign.id, attachment.id, (err, attachment) => {
if (err) {
return callback(err);
}
if (!attachment || !attachment.content) {
return setImmediate(getNextAttachment);
}

response.push(attachment);

// make sure we do not cache more buffers than 30MB
if (attachmentCacheSize + attachment.content.length > 30 * 1024 * 1024) {
attachmentCacheSize = 0;
attachmentCache.clear();
}

attachmentCache.set(aid, attachment);
attachmentCacheSize += attachment.content.length;

return setImmediate(getNextAttachment);
});
};

getNextAttachment();
});
}

function formatMessage(message, callback) {
campaigns.get(message.campaignId, false, (err, campaign) => {
if (err) {
Expand Down Expand Up @@ -255,71 +306,76 @@ function formatMessage(message, callback) {
}

// replace data: images with embedded attachments
let attachments = [];
html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
let cid = shortid.generate() + '-attachments@' + campaign.address.split('@').pop();
attachments.push({
path: dataUri,
cid
getAttachments(campaign, (err, attachments) => {
if (err) {
return callback(err);
}

html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
let cid = shortid.generate() + '-attachments@' + campaign.address.split('@').pop();
attachments.push({
path: dataUri,
cid
});
return prefix + 'cid:' + cid;
});
return prefix + 'cid:' + cid;
});

let campaignAddress = [campaign.cid, list.cid, message.subscription.cid].join('.');
let campaignAddress = [campaign.cid, list.cid, message.subscription.cid].join('.');

let renderedHtml = renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html) : html;
let renderedHtml = renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html) : html;

let renderedText = (text || '').trim() ? (renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, text) : text) : htmlToText.fromString(renderedHtml, {
wordwrap: 130
});
let renderedText = (text || '').trim() ? (renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, text) : text) : htmlToText.fromString(renderedHtml, {
wordwrap: 130
});

return callback(null, {
from: {
name: campaign.from,
address: campaign.address
},
xMailer: 'Mailtrain Mailer (+https://mailtrain.org)',
to: {
name: [].concat(message.subscription.firstName || []).concat(message.subscription.lastName || []).join(' '),
address: message.subscription.email
},
sender: useVerp ? campaignAddress + '@' + configItems.verpHostname : false,

envelope: useVerp ? {
from: campaignAddress + '@' + configItems.verpHostname,
to: message.subscription.email
} : false,

headers: {
'x-fbl': campaignAddress,
// custom header for SparkPost
'x-msys-api': JSON.stringify({
campaign_id: campaignAddress
}),
// custom header for SendGrid
'x-smtpapi': JSON.stringify({
unique_args: {
return callback(null, {
from: {
name: campaign.from,
address: campaign.address
},
xMailer: 'Mailtrain Mailer (+https://mailtrain.org)',
to: {
name: [].concat(message.subscription.firstName || []).concat(message.subscription.lastName || []).join(' '),
address: message.subscription.email
},
sender: useVerp ? campaignAddress + '@' + configItems.verpHostname : false,

envelope: useVerp ? {
from: campaignAddress + '@' + configItems.verpHostname,
to: message.subscription.email
} : false,

headers: {
'x-fbl': campaignAddress,
// custom header for SparkPost
'x-msys-api': JSON.stringify({
campaign_id: campaignAddress
}),
// custom header for SendGrid
'x-smtpapi': JSON.stringify({
unique_args: {
campaign_id: campaignAddress
}
}),
// custom header for Mailgun
'x-mailgun-variables': JSON.stringify({
campaign_id: campaignAddress
}),
'List-ID': {
prepared: true,
value: libmime.encodeWords(list.name) + ' <' + list.cid + '.' + (url.parse(configItems.serviceUrl).hostname || 'localhost') + '>'
}
}),
// custom header for Mailgun
'x-mailgun-variables': JSON.stringify({
campaign_id: campaignAddress
}),
'List-ID': {
prepared: true,
value: libmime.encodeWords(list.name) + ' <' + list.cid + '.' + (url.parse(configItems.serviceUrl).hostname || 'localhost') + '>'
}
},
list: {
unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid + '?auto=yes')
},
subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject),
html: renderedHtml,
text: renderedText,

attachments,
encryptionKeys
},
list: {
unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid + '?auto=yes')
},
subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject),
html: renderedHtml,
text: renderedText,

attachments,
encryptionKeys
});
});
});
};
Expand Down
Loading

0 comments on commit 35bce32

Please sign in to comment.