Merge branch 'v1.3' of github.com:andris9/mailtrain into v1.3

This commit is contained in:
Andris Reinman 2016-04-28 10:18:10 +03:00
commit 1b0b7d7a14
11 changed files with 250 additions and 141 deletions

View file

@ -37,6 +37,7 @@ Subscribe to Mailtrain Newsletter [here](http://mailtrain.org/subscription/EysIv
## Upgrade ## Upgrade
* Replace old files with new ones by running in the Mailtrain folder `git pull origin master` * Replace old files with new ones by running in the Mailtrain folder `git pull origin master`
* Run `npm install --production` in the Mailtrain folder
## Using environment variables ## Using environment variables
@ -80,9 +81,9 @@ In the IDE, start Mailtrain via `Run > Start Mailtrain` and access your site via
Mailtrain uses webhooks integration to detect bounces and spam complaints. Currently supported webhooks are: Mailtrain uses webhooks integration to detect bounces and spam complaints. Currently supported webhooks are:
* **AWS SES** create a SNS topic for complaints and bounces and use `http://domain/webhooks/aws` as the subscriber URL for these topics * **AWS SES** create a SNS topic for complaints and bounces and use `http://domain/webhooks/aws` as the subscriber URL for these topics
* **SparkPost** use `http://domain/webhooks/sparkpost` as the webhook URL for bounces and complaints * **SparkPost** use `http://domain/webhooks/sparkpost` as the webhook URL for bounces and complaints ([instructions](https://github.com/andris9/mailtrain/wiki/Setting-up-Webhooks-for-SparkPost))
* **SendGrid** use `http://domain/webhooks/sendgrid` as the webhook URL for bounces and complaints * **SendGrid** use `http://domain/webhooks/sendgrid` as the webhook URL for bounces and complaints ([instructions](https://github.com/andris9/mailtrain/wiki/Setting-up-Webhooks-for-SendGrid))
* **Mailgun** use `http://domain/webhooks/mailgun` as the webhook URL for bounces and complaints * **Mailgun** use `http://domain/webhooks/mailgun` as the webhook URL for bounces and complaints ([instructions](https://github.com/andris9/mailtrain/wiki/Setting-up-Webhooks-for-Mailgun))
Additionally Mailtrain (v1.1+) is able to use VERP-based bounce handling. This would require to have a compatible SMTP relay (the services mentioned above strip out or block VERP addresses in the SMTP envelope) and you also need to set up special MX DNS name that points to your Mailtrain installation server. Additionally Mailtrain (v1.1+) is able to use VERP-based bounce handling. This would require to have a compatible SMTP relay (the services mentioned above strip out or block VERP addresses in the SMTP envelope) and you also need to set up special MX DNS name that points to your Mailtrain installation server.

View file

@ -9,6 +9,7 @@ let Handlebars = require('handlebars');
let fs = require('fs'); let fs = require('fs');
let path = require('path'); let path = require('path');
let templates = new Map(); let templates = new Map();
let htmlToText = require('html-to-text');
module.exports.transport = false; module.exports.transport = false;
@ -54,6 +55,10 @@ module.exports.sendMail = (mail, template, callback) => {
if (textRenderer) { if (textRenderer) {
mail.text = textRenderer(template.data || {}); mail.text = textRenderer(template.data || {});
} else if (mail.html) {
mail.text = htmlToText.fromString(mail.html, {
wordwrap: 130
});
} }
module.exports.transport.sendMail(mail, callback); module.exports.transport.sendMail(mail, callback);

View file

@ -8,7 +8,7 @@ let segments = require('./segments');
let subscriptions = require('./subscriptions'); let subscriptions = require('./subscriptions');
let shortid = require('shortid'); let shortid = require('shortid');
let allowedKeys = ['description', 'from', 'address', 'subject', 'template', 'list', 'segment', 'html', 'text']; let allowedKeys = ['description', 'from', 'address', 'subject', 'template', 'template_url', 'list', 'segment', 'html', 'text'];
module.exports.list = (start, limit, callback) => { module.exports.list = (start, limit, callback) => {
db.getConnection((err, connection) => { db.getConnection((err, connection) => {

View file

@ -15,7 +15,8 @@ module.exports = {
createSlug, createSlug,
updateMenu, updateMenu,
validateEmail, validateEmail,
formatMessage formatMessage,
getMessageLinks
}; };
function toDbKey(key) { function toDbKey(key) {
@ -147,17 +148,23 @@ function validateEmail(address, checkBlocked, callback) {
}); });
} }
function getMessageLinks(serviceUrl, campaign, list, subscription) {
return {
LINK_UNSUBSCRIBE: urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?auto=yes&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)
};
}
function formatMessage(serviceUrl, campaign, list, subscription, message, filter) { function formatMessage(serviceUrl, campaign, list, subscription, message, filter) {
filter = typeof filter === 'function' ? filter : (str => str); filter = typeof filter === 'function' ? filter : (str => str);
let links = getMessageLinks(serviceUrl, campaign, list, subscription);
let getValue = key => { let getValue = key => {
switch ((key || '').toString().toUpperCase().trim()) { key = (key || '').toString().toUpperCase().trim();
case 'LINK_UNSUBSCRIBE': if (links.hasOwnProperty(key)) {
return urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?auto=yes&c=' + campaign.cid); return links[key];
case 'LINK_PREFERENCES':
return urllib.resolve(serviceUrl, '/subscription/' + list.cid + '/manage/' + subscription.cid);
case 'LINK_BROWSER':
return urllib.resolve(serviceUrl, '/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid);
} }
if (subscription.mergeTags.hasOwnProperty(key)) { if (subscription.mergeTags.hasOwnProperty(key)) {
return subscription.mergeTags[key]; return subscription.mergeTags[key];

View file

@ -1,3 +1,3 @@
{ {
"schemaVersion": 3 "schemaVersion": 4
} }

View file

@ -44,6 +44,7 @@
"geoip-ultralight": "^0.1.3", "geoip-ultralight": "^0.1.3",
"handlebars": "^4.0.5", "handlebars": "^4.0.5",
"hbs": "^4.0.0", "hbs": "^4.0.0",
"html-to-text": "^2.1.0",
"humanize": "0.0.9", "humanize": "0.0.9",
"isemail": "^2.1.0", "isemail": "^2.1.0",
"morgan": "^1.7.0", "morgan": "^1.7.0",

View file

@ -7,6 +7,7 @@ let lists = require('../lib/models/lists');
let subscriptions = require('../lib/models/subscriptions'); let subscriptions = require('../lib/models/subscriptions');
let tools = require('../lib/tools'); let tools = require('../lib/tools');
let express = require('express'); let express = require('express');
let request = require('request');
let router = new express.Router(); let router = new express.Router();
router.get('/:campaign/:list/:subscription', (req, res, next) => { router.get('/:campaign/:list/:subscription', (req, res, next) => {
@ -64,21 +65,45 @@ router.get('/:campaign/:list/:subscription', (req, res, next) => {
return next(err); return next(err);
} }
// rewrite links to count clicks let renderAndShow = (html, renderTags) => {
links.updateLinks(campaign, list, subscription, serviceUrl, campaign.html, (err, html) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
res.render('archive/view', { // rewrite links to count clicks
layout: 'archive/layout', links.updateLinks(campaign, list, subscription, serviceUrl, html, (err, html) => {
message: tools.formatMessage(serviceUrl, campaign, list, subscription, html), if (err) {
campaign, req.flash('danger', err.message || err);
list, return res.redirect('/');
subscription }
res.render('archive/view', {
layout: 'archive/layout',
message: renderTags ? tools.formatMessage(serviceUrl, campaign, list, subscription, html) : html,
campaign,
list,
subscription
});
}); });
}); };
if (campaign.templateUrl) {
let form = tools.getMessageLinks(serviceUrl, campaign, list, subscription);
Object.keys(subscription.mergeTags).forEach(key => {
form[key] = subscription.mergeTags[key];
});
request.post({
url: campaign.templateUrl,
form
}, (err, httpResponse, body) => {
if (err) {
return next(err);
}
if (httpResponse.statusCode !== 200) {
return next(new Error('Received status code ' + httpResponse.statusCode + ' from ' + campaign.templateUrl));
}
renderAndShow(body && body.toString(), false);
});
} else {
renderAndShow(campaign.html, true);
}
}); });
}); });
}); });

View file

@ -13,6 +13,8 @@ let settings = require('../lib/models/settings');
let links = require('../lib/models/links'); let links = require('../lib/models/links');
let shortid = require('shortid'); let shortid = require('shortid');
let url = require('url'); let url = require('url');
let htmlToText = require('html-to-text');
let request = require('request');
function findUnsent(callback) { function findUnsent(callback) {
db.getConnection((err, connection) => { db.getConnection((err, connection) => {
@ -149,73 +151,102 @@ function formatMessage(message, callback) {
} }
}); });
links.updateLinks(campaign, list, message.subscription, configItems.serviceUrl, campaign.html, (err, html) => { let renderAndSend = (html, text, renderTags) => {
if (err) { links.updateLinks(campaign, list, message.subscription, configItems.serviceUrl, html, (err, html) => {
return callback(err); if (err) {
} return callback(err);
}
// replace data: images with embedded attachments // replace data: images with embedded attachments
let attachments = []; let attachments = [];
html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => { html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
let cid = shortid.generate() + '-attachments@' + campaign.address.split('@').pop(); let cid = shortid.generate() + '-attachments@' + campaign.address.split('@').pop();
attachments.push({ attachments.push({
path: dataUri, path: dataUri,
cid 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('.');
return callback(null, { let renderedHtml = renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html) : html;
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 ? { let renderedText = (text || '').trim() ? (renderTags ? tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, text) : text) : htmlToText.fromString(renderedHtml, {
from: campaignAddress + '@' + configItems.verpHostname, wordwrap: 130
to: message.subscription.email });
} : false,
headers: { return callback(null, {
'x-fbl': campaignAddress, from: {
// custom header for SparkPost name: campaign.from,
'x-msys-api': JSON.stringify({ address: campaign.address
campaign_id: campaignAddress },
}), xMailer: 'Mailtrain Mailer (+https://mailtrain.org)',
// custom header for SendGrid to: {
'x-smtpapi': JSON.stringify({ name: [].concat(message.subscription.firstName || []).concat(message.subscription.lastName || []).join(' '),
unique_args: { 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 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: '"' + list.name.replace(/[^a-z0-9\s'.,\-]/g, '').trim() + '" <' + list.cid + '.' + (url.parse(configItems.serviceUrl).hostname || 'localhost') + '>'
} }
}), },
// custom header for Mailgun list: {
'x-mailgun-variables': JSON.stringify({ unsubscribe: url.resolve(configItems.serviceUrl, '/subscription/' + list.cid + '/unsubscribe/' + message.subscription.cid + '?auto=yes')
campaign_id: campaignAddress },
}), subject: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.subject),
'List-ID': { html: renderedHtml,
prepared: true, text: renderedText,
value: '"' + list.name.replace(/[^a-z0-9\s'.,\-]/g, '').trim() + '" <' + 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: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, html),
text: tools.formatMessage(configItems.serviceUrl, campaign, list, message.subscription, campaign.text),
attachments, attachments,
encryptionKeys encryptionKeys
});
}); });
}); };
if (campaign.templateUrl) {
let form = tools.getMessageLinks(configItems.serviceUrl, campaign, list, message.subscription);
Object.keys(message.subscription.mergeTags).forEach(key => {
form[key] = message.subscription.mergeTags[key];
});
request.post({
url: campaign.templateUrl,
form
}, (err, httpResponse, body) => {
if (err) {
return callback(err);
}
if (httpResponse.statusCode !== 200) {
return callback(new Error('Received status code ' + httpResponse.statusCode + ' from ' + campaign.templateUrl));
}
renderAndSend(body && body.toString(), '', false);
});
} else {
renderAndSend(campaign.html, campaign.text, true);
}
}); });
}); });
}); });

View file

@ -0,0 +1,12 @@
# Header section
# Define incrementing schema version number
SET @schema_version = '4';
# Adds new column 'template_url' to campaigns table
# Indicates that this campaign should fetch message content from this URL
ALTER TABLE `campaigns` ADD COLUMN `template_url` varchar(255) CHARACTER SET ascii DEFAULT NULL AFTER `template`;
# Footer section
LOCK TABLES `settings` WRITE;
INSERT INTO `settings` (`key`, `value`) VALUES('db_schema_version', @schema_version) ON DUPLICATE KEY UPDATE `value`=@schema_version;
UNLOCK TABLES;

View file

@ -53,15 +53,29 @@
<div class="form-group"> <div class="form-group">
<label for="template" class="col-sm-2 control-label">Template</label> <label for="template" class="col-sm-2 control-label">Template</label>
<div class="col-sm-10"> <div class="col-sm-10">
<select class="form-control" id="template" name="template">
<option value=""> Select </option> <p class="form-control-static">
{{#each templateItems}} Select a template:
<option value="{{id}}" {{#if selected}} selected {{/if}}> </p>
{{name}} <div>
</option> <select class="form-control" id="template" name="template">
{{/each}} <option value=""> Select </option>
</select> {{#each templateItems}}
<span class="help-block">Not required. Creates a campaign specific copy from a template that you can later edit</span> <option value="{{id}}" {{#if selected}} selected {{/if}}>
{{name}}
</option>
{{/each}}
</select>
<span class="help-block">Selecting a template creates a campaign specific copy from it</span>
</div>
<p class="form-control-static">
Or alternatively use an URL as the message content source:
</p>
<div>
<input type="url" class="form-control" name="template-url" id="template-url" value="{{templateUrl}}" placeholder="http://example.com/message-render.php">
<span class="help-block">If a message is sent then this URL will be POSTed to using Merge Tags as POST body. Use this if you want to generate the HTML message yourself</span>
</div>
</div> </div>
</div> </div>

View file

@ -106,60 +106,73 @@
Template Settings Template Settings
</legend> </legend>
<div class="form-group"> {{#if templateUrl}}
<div class="col-sm-offset-2 col-sm-10"> <div class="form-group">
<a class="btn btn-default" role="button" data-toggle="collapse" href="#mergeReference" aria-expanded="false" aria-controls="mergeReference">Merge tag reference</a> <label for="template-url" class="col-sm-2 control-label">Template URL</label>
<div class="collapse" id="mergeReference"> <div class="col-sm-10">
<p> <input type="url" class="form-control" name="template-url" id="template-url" value="{{templateUrl}}" placeholder="http://example.com/message-render.php">
Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>[TAG_NAME]</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional <span class="help-block">If a message is sent then this URL will be POSTed to using Merge Tags as POST body. Use this if you want to generate the HTML message yourself</span>
text value used when <code>TAG_NAME</code> is empty.
</p>
<ul>
<li>
<code>[FIRST_NAME]</code> first name of the subcriber
</li>
<li>
<code>[LAST_NAME]</code> last name of the subcriber
</li>
<li>
<code>[FULL_NAME]</code> first and last names of the subcriber joined
</li>
<li>
<code>[LINK_UNSUBSCRIBE]</code> URL that points to the preferences page of the subscriber
</li>
<li>
<code>[LINK_PREFERENCES]</code> URL that points to the unsubscribe page
</li>
<li>
<code>[LINK_BROWSER]</code> URL to preview the message in a browser
</li>
</ul>
<p>
In addition to that any custom field can have its own merge tag.
</p>
</div> </div>
</div> </div>
</div> {{else}}
<div class="form-group">
<label for="template-html" class="col-sm-2 control-label">Template content (HTML)</label>
<div class="col-sm-10">
{{#if disableWysiwyg}}
<div class="code-editor" id="template-html">{{html}}</div>
<input type="hidden" name="html">
{{else}}
<textarea class="form-control summernote" id="template-html" name="html" rows="8">{{html}}</textarea>
{{/if}}
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="template-text" class="col-sm-2 control-label">Template content (plaintext)</label> <div class="col-sm-offset-2 col-sm-10">
<div class="col-sm-10"> <a class="btn btn-default" role="button" data-toggle="collapse" href="#mergeReference" aria-expanded="false" aria-controls="mergeReference">Merge tag reference</a>
<textarea class="form-control" id="template-text" name="text" rows="10">{{text}}</textarea> <div class="collapse" id="mergeReference">
<p>
Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>[TAG_NAME]</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional
text value used when <code>TAG_NAME</code> is empty.
</p>
<ul>
<li>
<code>[FIRST_NAME]</code> first name of the subcriber
</li>
<li>
<code>[LAST_NAME]</code> last name of the subcriber
</li>
<li>
<code>[FULL_NAME]</code> first and last names of the subcriber joined
</li>
<li>
<code>[LINK_UNSUBSCRIBE]</code> URL that points to the preferences page of the subscriber
</li>
<li>
<code>[LINK_PREFERENCES]</code> URL that points to the unsubscribe page
</li>
<li>
<code>[LINK_BROWSER]</code> URL to preview the message in a browser
</li>
</ul>
<p>
In addition to that any custom field can have its own merge tag.
</p>
</div>
</div>
</div> </div>
</div>
<div class="form-group">
<label for="template-html" class="col-sm-2 control-label">Template content (HTML)</label>
<div class="col-sm-10">
{{#if disableWysiwyg}}
<div class="code-editor" id="template-html">{{html}}</div>
<input type="hidden" name="html">
{{else}}
<textarea class="form-control summernote" id="template-html" name="html" rows="8">{{html}}</textarea>
{{/if}}
</div>
</div>
<div class="form-group">
<label for="template-text" class="col-sm-2 control-label">Template content (plaintext)</label>
<div class="col-sm-10">
<textarea class="form-control" id="template-text" name="text" rows="10">{{text}}</textarea>
</div>
</div>
{{/if}}
</fieldset> </fieldset>
</div> </div>
</div> </div>