diff --git a/README.md b/README.md index 33c7a9f1..721ffe42 100644 --- a/README.md +++ b/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: + +``` +
+ Subscribe to our list +
+ +``` + ## Cloudron You can easily install and self-host Mailtrain on the Cloudron to send newsletters from your custom domain: diff --git a/config/default.toml b/config/default.toml index 2d8849ab..6293d407 100644 --- a/config/default.toml +++ b/config/default.toml @@ -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"]] diff --git a/package.json b/package.json index 1e017ba6..10af0858 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/subscription/widget.js b/public/subscription/widget.js new file mode 100644 index 00000000..3aa8c346 --- /dev/null +++ b/public/subscription/widget.js @@ -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); +} diff --git a/routes/subscription.js b/routes/subscription.js index 1e202ab6..188b9654 100644 --- a/routes/subscription.js +++ b/routes/subscription.js @@ -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); diff --git a/views/subscription/widget-subscribe.hbs b/views/subscription/widget-subscribe.hbs new file mode 100644 index 00000000..4f770c55 --- /dev/null +++ b/views/subscription/widget-subscribe.hbs @@ -0,0 +1,35 @@ +
+ {{#if hasPubkey}} + + {{/if}} + +

{{title}}

+ +
+ + + + {{> subscription_custom_fields}} + +
+ +
+ +
+
+

{{#translate}}Sending ...{{/translate}}

+
+
+

{{#translate}}It looks like you are already subscribed to this list.{{/translate}}

+
+
+

{{#translate}}Almost Finished{{/translate}}

+

{{#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}}

+
+
+

{message}

+
+
+