Subscription Widget
This commit is contained in:
parent
67eae8616d
commit
60907c0d86
6 changed files with 361 additions and 4 deletions
22
README.md
22
README.md
|
@ -19,6 +19,7 @@ Subscribe to Mailtrain Newsletter [here](http://mailtrain.org/subscription/EysIv
|
|||
* [Installation](#installation)
|
||||
* [Upgrade](#upgrade)
|
||||
* [Using Environment Variables](#using-environment-variables)
|
||||
* [Subscription Widget](#subscription-widget)
|
||||
* [Cloudron](#cloudron)
|
||||
* [Bounce Handling](#bounce-handling)
|
||||
* [Testing](#testing)
|
||||
|
@ -233,6 +234,27 @@ Edit [mailtrain.nginx](setup/mailtrain-nginx.conf) (update `server_name` directi
|
|||
|
||||
Edit [mailtrain.conf](setup/mailtrain.conf) (update application folder) and copy it to `/etc/init`
|
||||
|
||||
## Subscription Widget
|
||||
|
||||
The (experimental) Mailtrain Subscription Widget allows you to embed your sign-up forms on your website. To embed a Widget, you need to:
|
||||
|
||||
Enable cross-origin resource sharing in your `config` file and whitelist your site:
|
||||
|
||||
```
|
||||
[cors]
|
||||
# Allow subscription widgets to be embedded
|
||||
origins=['https://www.example.com']
|
||||
```
|
||||
|
||||
Embed the widget code on your website:
|
||||
|
||||
```
|
||||
<div data-mailtrain-subscription-widget data-url="http://domain/subscription/Byf44R-og/widget">
|
||||
<a href="http://domain/subscription/Byf44R-og">Subscribe to our list</a>
|
||||
</div>
|
||||
<script src="http://domain/subscription/widget.js"></script>
|
||||
```
|
||||
|
||||
## Cloudron
|
||||
|
||||
You can easily install and self-host Mailtrain on the Cloudron to send newsletters from your custom domain:
|
||||
|
|
|
@ -137,6 +137,10 @@ host="127.0.0.1"
|
|||
# You can use more than 1 process only if you have Redis enabled
|
||||
processes=1
|
||||
|
||||
[cors]
|
||||
# Allow subscription widgets to be embedded
|
||||
# origins=['https://www.example.com']
|
||||
|
||||
[mosaico]
|
||||
# Installed templates
|
||||
templates=[["versafix-1", "Versafix One"]]
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
"connect-flash": "^0.1.1",
|
||||
"connect-redis": "^3.2.0",
|
||||
"cookie-parser": "^1.4.3",
|
||||
"cors": "^2.8.3",
|
||||
"csurf": "^1.9.0",
|
||||
"csv-generate": "^1.0.0",
|
||||
"csv-parse": "^1.2.0",
|
||||
|
|
176
public/subscription/widget.js
Normal file
176
public/subscription/widget.js
Normal file
|
@ -0,0 +1,176 @@
|
|||
/* eslint-env browser */
|
||||
/* eslint prefer-arrow-callback: 0, object-shorthand: 0, new-cap: 0, no-invalid-this: 0, no-var: 0*/
|
||||
|
||||
if (typeof window.mailtrain !== 'object') {
|
||||
window.mailtrain = {};
|
||||
(function(mt) {
|
||||
'use strict';
|
||||
|
||||
if (document.documentMode <= 9 || typeof XMLHttpRequest === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
var cookie = {
|
||||
create: function(name, value, days) {
|
||||
var date = new Date();
|
||||
date.setTime(date.getTime() + ((days || 365) * 24 * 60 * 60 * 1000));
|
||||
var expires = '; expires=' + date.toGMTString();
|
||||
document.cookie = name + '=' + value + expires + '; path=/';
|
||||
},
|
||||
read: function(name) {
|
||||
var a = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||||
return a ? a.pop() : null;
|
||||
}
|
||||
};
|
||||
|
||||
var forEach = function(array, callback, scope) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
callback.call(scope, i, array[i]);
|
||||
}
|
||||
};
|
||||
|
||||
var loadScript = function(exists, url, callback) {
|
||||
if (eval(exists)) {
|
||||
return callback(true);
|
||||
}
|
||||
var scriptTag = document.createElement('script');
|
||||
scriptTag.setAttribute('type', 'text/javascript');
|
||||
scriptTag.setAttribute('src', url);
|
||||
if (typeof callback !== 'undefined') {
|
||||
if (scriptTag.readyState) {
|
||||
scriptTag.onreadystatechange = function() {
|
||||
(this.readyState === 'complete' || this.readyState === 'loaded') && callback();
|
||||
};
|
||||
} else {
|
||||
scriptTag.onload = callback;
|
||||
}
|
||||
}
|
||||
(document.getElementsByTagName('head')[0] || document.documentElement).appendChild(scriptTag);
|
||||
};
|
||||
|
||||
var serialize = function(form) {
|
||||
var field, s = [];
|
||||
if (typeof form === 'object' && form.nodeName === 'FORM') {
|
||||
var len = form.elements.length;
|
||||
for (var i = 0; i < len; i++) {
|
||||
field = form.elements[i];
|
||||
if (field.name && !field.disabled && field.type !== 'file' && field.type !== 'reset' && field.type !== 'submit' && field.type !== 'button') {
|
||||
if (field.type === 'select-multiple') {
|
||||
for (var j = form.elements[i].options.length - 1; j >= 0; j--) {
|
||||
if (field.options[j].selected) {
|
||||
s[s.length] = encodeURIComponent(field.name) + '=' + encodeURIComponent(field.options[j].value);
|
||||
}
|
||||
}
|
||||
} else if ((field.type !== 'checkbox' && field.type !== 'radio') || field.checked) {
|
||||
s[s.length] = encodeURIComponent(field.name) + '=' + encodeURIComponent(field.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.join('&').replace(/%20/g, '+');
|
||||
};
|
||||
|
||||
var getXHR = function(method, url, successHandler, errorHandler) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url, true);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch(err) {
|
||||
data = { error: err.message || err };
|
||||
}
|
||||
xhr.status === 200 && !data.error
|
||||
? successHandler && successHandler(data)
|
||||
: errorHandler && errorHandler(data);
|
||||
}
|
||||
};
|
||||
return xhr;
|
||||
};
|
||||
|
||||
var getJSON = function(url, successHandler, errorHandler) {
|
||||
var xhr = getXHR('get', url, successHandler, errorHandler);
|
||||
xhr.send();
|
||||
};
|
||||
|
||||
var sendForm = function(form, successHandler, errorHandler) {
|
||||
var url = form.getAttribute('action');
|
||||
var xhr = getXHR('post', url, successHandler, errorHandler);
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
|
||||
xhr.send(serialize(form));
|
||||
};
|
||||
|
||||
var renderWidget = function(container, data) {
|
||||
container.innerHTML = data.html;
|
||||
|
||||
var form = container.querySelector('.form');
|
||||
var statusContainer = container.querySelector('.status');
|
||||
|
||||
var setStatus = function(templateName, message) {
|
||||
var html = container.querySelector('div[data-status-template="' + templateName + '"]').outerHTML;
|
||||
html = message ? html.replace('{message}', message) : html;
|
||||
statusContainer.innerHTML = html;
|
||||
};
|
||||
|
||||
if (cookie.read('has-subscribed-to-' + data.cid)) {
|
||||
setStatus('already-subscribed');
|
||||
form.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
container.querySelector('.sub-time').value = new Date().getTime();
|
||||
|
||||
if (window.moment && window.moment.tz) {
|
||||
container.querySelector('.tz-detect').value = window.moment.tz.guess() || '';
|
||||
}
|
||||
|
||||
var isSending = false;
|
||||
|
||||
form.addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isSending) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
setStatus('spinner');
|
||||
|
||||
sendForm(form, function(j) {
|
||||
isSending = false;
|
||||
setStatus('confirm-notice');
|
||||
form.style.display = 'none';
|
||||
container.scrollIntoView();
|
||||
cookie.create('has-subscribed-to-' + data.cid, 1);
|
||||
}, function(j) {
|
||||
isSending = false;
|
||||
setStatus('error', j.error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
forEach(document.body.querySelectorAll('div[data-mailtrain-subscription-widget]'), function(i, container) {
|
||||
var url = container.getAttribute('data-url');
|
||||
getJSON(url, function(j) {
|
||||
renderWidget(container, j.data);
|
||||
}, function(j) {
|
||||
console.log(j);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadScript('window.moment', 'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.12.0/moment.min.js', function(existed) {
|
||||
loadScript('window.moment.tz', 'https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.3/moment-timezone-with-data.min.js', function(existed) {
|
||||
if (window.moment && window.moment.tz) {
|
||||
forEach(document.body.querySelectorAll('div[data-mailtrain-subscription-widget] .tz-detect'), function(i, el) {
|
||||
el.value = window.moment.tz.guess() || '';
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
})(window.mailtrain);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
let log = require('npmlog');
|
||||
|
||||
let config = require('config');
|
||||
let tools = require('../lib/tools');
|
||||
let helpers = require('../lib/helpers');
|
||||
let mailer = require('../lib/mailer');
|
||||
|
@ -16,6 +16,33 @@ let settings = require('../lib/models/settings');
|
|||
let openpgp = require('openpgp');
|
||||
let _ = require('../lib/translate')._;
|
||||
let util = require('util');
|
||||
let cors = require('cors');
|
||||
let cache = require('memory-cache');
|
||||
|
||||
let originWhitelist = config.cors && config.cors.origins || [];
|
||||
|
||||
let corsOptions = {
|
||||
allowedHeaders: ['Content-Type', 'Origin', 'Accept', 'X-Requested-With'],
|
||||
methods: ['GET', 'POST'],
|
||||
optionsSuccessStatus: 200, // IE11 chokes on 204
|
||||
origin: (origin, callback) => {
|
||||
if (originWhitelist.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
let err = new Error(_('Not allowed by CORS'));
|
||||
err.status = 403;
|
||||
callback(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let corsOrCsrfProtection = (req, res, next) => {
|
||||
if (req.get('X-Requested-With') === 'XMLHttpRequest') {
|
||||
cors(corsOptions)(req, res, next);
|
||||
} else {
|
||||
passport.csrfProtection(req, res, next);
|
||||
}
|
||||
};
|
||||
|
||||
router.get('/subscribe/:cid', (req, res, next) => {
|
||||
subscriptions.subscribe(req.params.cid, req.ip, (err, subscription) => {
|
||||
|
@ -214,6 +241,77 @@ router.get('/:cid', passport.csrfProtection, (req, res, next) => {
|
|||
});
|
||||
});
|
||||
|
||||
router.options('/:cid/widget', cors(corsOptions));
|
||||
|
||||
router.get('/:cid/widget', cors(corsOptions), (req, res, next) => {
|
||||
let cached = cache.get(req.path);
|
||||
if (cached) {
|
||||
return res.status(200).json(cached);
|
||||
}
|
||||
|
||||
let sendError = err => {
|
||||
res.status(err.status || 500);
|
||||
res.json({
|
||||
error: err.message || err
|
||||
});
|
||||
};
|
||||
|
||||
lists.getByCid(req.params.cid, (err, list) => {
|
||||
if (!err && !list) {
|
||||
err = new Error(_('Selected list not found'));
|
||||
err.status = 404;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return sendError(err);
|
||||
}
|
||||
|
||||
fields.list(list.id, (err, fieldList) => {
|
||||
if (err && !fieldList) {
|
||||
fieldList = [];
|
||||
}
|
||||
|
||||
settings.list(['serviceUrl', 'pgpPrivateKey'], (err, configItems) => {
|
||||
if (err) {
|
||||
return sendError(err);
|
||||
}
|
||||
|
||||
let data = {
|
||||
title: list.name,
|
||||
cid: list.cid,
|
||||
serviceUrl: configItems.serviceUrl,
|
||||
hasPubkey: !!configItems.pgpPrivateKey,
|
||||
customFields: fields.getRow(fieldList),
|
||||
layout: null,
|
||||
};
|
||||
|
||||
helpers.injectCustomFormData(req.query.fid || list.defaultForm, 'subscription/web-subscribe', data, (err, data) => {
|
||||
if (err) {
|
||||
return sendError(err);
|
||||
}
|
||||
|
||||
res.render('subscription/widget-subscribe', data, (err, html) => {
|
||||
if (err) {
|
||||
return sendError(err);
|
||||
}
|
||||
|
||||
let response = {
|
||||
data: {
|
||||
title: data.title,
|
||||
cid: data.cid,
|
||||
html
|
||||
}
|
||||
};
|
||||
|
||||
cache.put(req.path, response, 30000); // ms
|
||||
res.status(200).json(response);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:cid/confirm-notice', (req, res, next) => {
|
||||
lists.getByCid(req.params.cid, (err, list) => {
|
||||
if (!err && !list) {
|
||||
|
@ -372,10 +470,22 @@ router.get('/:cid/unsubscribe-notice', (req, res, next) => {
|
|||
});
|
||||
});
|
||||
|
||||
router.post('/:cid/subscribe', passport.parseForm, passport.csrfProtection, (req, res, next) => {
|
||||
router.options('/:cid/subscribe', cors(corsOptions));
|
||||
|
||||
router.post('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, (req, res, next) => {
|
||||
let sendJsonError = (err, status) => {
|
||||
res.status(status || err.status || 500);
|
||||
res.json({
|
||||
error: err.message || err
|
||||
});
|
||||
};
|
||||
|
||||
let email = (req.body.email || '').toString().trim();
|
||||
|
||||
if (!email) {
|
||||
if (req.xhr) {
|
||||
return sendJsonError(_('Email address not set'), 400);
|
||||
}
|
||||
req.flash('danger', _('Email address not set'));
|
||||
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
|
||||
}
|
||||
|
@ -396,7 +506,7 @@ router.post('/:cid/subscribe', passport.parseForm, passport.csrfProtection, (req
|
|||
}
|
||||
|
||||
if (err) {
|
||||
return next(err);
|
||||
return req.xhr ? sendJsonError(err) : next(err);
|
||||
}
|
||||
|
||||
let data = {};
|
||||
|
@ -416,11 +526,20 @@ router.post('/:cid/subscribe', passport.parseForm, passport.csrfProtection, (req
|
|||
if (!err && !confirmCid) {
|
||||
err = new Error(_('Could not store confirmation data'));
|
||||
}
|
||||
|
||||
if (err) {
|
||||
if (req.xhr) {
|
||||
return sendJsonError(err);
|
||||
}
|
||||
req.flash('danger', err.message || err);
|
||||
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
|
||||
}
|
||||
|
||||
if (req.xhr) {
|
||||
return res.status(200).json({
|
||||
msg: _('Please Confirm Subscription')
|
||||
});
|
||||
}
|
||||
res.redirect('/subscription/' + req.params.cid + '/confirm-notice');
|
||||
});
|
||||
});
|
||||
|
@ -766,7 +885,7 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (
|
|||
});
|
||||
});
|
||||
|
||||
router.post('/publickey', passport.parseForm, passport.csrfProtection, (req, res, next) => {
|
||||
router.post('/publickey', passport.parseForm, (req, res, next) => {
|
||||
settings.list(['pgpPassphrase', 'pgpPrivateKey'], (err, configItems) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
|
|
35
views/subscription/widget-subscribe.hbs
Normal file
35
views/subscription/widget-subscribe.hbs
Normal file
|
@ -0,0 +1,35 @@
|
|||
<div id="mailtrain-subscription-widget-{{cid}}" class="mailtrain-subscription-widget">
|
||||
{{#if hasPubkey}}
|
||||
<form method="post" id="download-pubkey" action="{{serviceUrl}}subscription/publickey" style="display: none;">
|
||||
<input type="hidden" name="cid" value="{{cid}}">
|
||||
</form>
|
||||
{{/if}}
|
||||
|
||||
<h3>{{title}}</h3>
|
||||
|
||||
<form class="form" method="post" action="{{serviceUrl}}subscription/{{cid}}/subscribe">
|
||||
<input type="hidden" class="tz-detect" name="tz" value="">
|
||||
<input type="hidden" name="address" value="">
|
||||
<input type="hidden" class="sub-time" name="sub" value="">
|
||||
{{> subscription_custom_fields}}
|
||||
<button type="submit">{{#translate}}Subscribe to list{{/translate}}</button>
|
||||
</form>
|
||||
|
||||
<div class="status"></div>
|
||||
|
||||
<div style="display: none;">
|
||||
<div class="spinner" data-status-template="spinner">
|
||||
<p>{{#translate}}Sending ...{{/translate}}</p>
|
||||
</div>
|
||||
<div class="info" data-status-template="already-subscribed">
|
||||
<p>{{#translate}}It looks like you are already subscribed to this list.{{/translate}}</p>
|
||||
</div>
|
||||
<div class="success" data-status-template="confirm-notice">
|
||||
<h4>{{#translate}}Almost Finished{{/translate}}</h4>
|
||||
<p>{{#translate}}We need to confirm your email address. To complete the subscription process, please click the link in the email we just sent you.{{/translate}}</p>
|
||||
</div>
|
||||
<div class="error" data-status-template="error">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
Loading…
Add table
Add a link
Reference in a new issue