Merge branch 'master' of github.com:Mailtrain-org/mailtrain

This commit is contained in:
Tomas Bures 2017-06-01 08:04:56 -04:00
commit 6f554038c9
61 changed files with 2497 additions and 707 deletions

4
.gitignore vendored
View file

@ -1,3 +1,6 @@
/.idea
/last-failed-e2e-test.*
node_modules
npm-debug.log
.DS_Store
@ -23,3 +26,4 @@ public/grapejs/uploads/*
!public/grapejs/uploads/README.md
public/grapejs/templates/*
!public/grapejs/templates/demo
!public/grapejs/templates/aves

18
.travis.yml Normal file
View file

@ -0,0 +1,18 @@
dist: trusty
sudo: required
language: node_js
node_js:
- 7
services:
- mysql
before_install:
- sudo apt-get -q -y install pwgen imagemagick
install:
- sudo bash test/e2e/install.sh
- npm install
before_script:
- npm run starttest > /dev/null 2>&1 &
- sleep 10
script:
- grunt
- npm run _e2e

View file

@ -1,6 +1,6 @@
# Mailtrain
[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v5+) and MySQL (v5.5+ or MariaDB).
[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v7+) and MySQL (v5.5+ or MariaDB).
![](http://mailtrain.org/mailtrain.png)
@ -47,7 +47,7 @@ Check out [ZoneMTA](https://github.com/zone-eu/zone-mta) as an alternative self
## Requirements
* Nodejs v6+
* Nodejs v7+
* MySQL v5.5 or MariaDB
* Redis. Optional, disabled by default. Used for session storage and for caching state between multiple processes. If you do not have Redis enabled then you can only use a single sender process

5
app.js
View file

@ -184,8 +184,9 @@ app.use((req, res, next) => {
res.locals.customScripts = config.customscripts || [];
let bodyClasses = [];
app.get('env') === 'test' && bodyClasses.push('page--' + (req.path.substring(1).replace(/\//g, '--') || 'home'));
req.user && bodyClasses.push('logged-in user-' + req.user.username);
if (req.user) {
bodyClasses.push('logged-in user-' + req.user.username);
}
res.locals.bodyClass = bodyClasses.join(' ');
settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => {

View file

@ -151,7 +151,10 @@ templates=[["versafix-1", "Versafix One"]]
[grapejs]
# Installed templates
templates=[["demo", "Demo Template"]]
templates=[
["demo", "HTML Template"],
["aves", "MJML Template"]
]
[reports]
# The whole reporting functionality can be disabled below if the they are not needed and the DB cannot be

View file

@ -4,6 +4,7 @@ let config = require('config');
let mysql = require('mysql');
let redis = require('redis');
let Lock = require('redfour');
let tools = require('./tools');
module.exports = mysql.createPool(config.mysql);
if (config.redis && config.redis.enabled) {
@ -33,7 +34,7 @@ if (config.redis && config.redis.enabled) {
};
module.exports.clearCache = (key, callback) => {
module.exports.redis.del(key, err => callback(err));
module.exports.redis.del('mailtrain:cache:' + key, err => callback(err));
};
module.exports.addToCache = (key, value, callback) => {
@ -76,9 +77,21 @@ if (config.redis && config.redis.enabled) {
module.exports.clearCache = (key, callback) => {
caches.delete(key);
tools.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, []);

View file

@ -182,10 +182,10 @@ function createMailer(callback) {
module.exports.transport.checkThrottling = null;
}
let throttling = Number(configItems.smtpThrottling) || 0;
if (throttling) {
let sendingRate = Number(configItems.smtpThrottling) || 0;
if (sendingRate) {
// convert to messages/second
throttling = 1 / (throttling / (3600 * 1000));
sendingRate = sendingRate / 3600;
}
let transportOptions;
@ -236,7 +236,7 @@ function createMailer(callback) {
error: logfunc.bind(null, 'error')
},
maxConnections: Number(configItems.smtpMaxConnections),
sendingRate: throttling,
sendingRate,
tls: {
rejectUnauthorized: !configItems.smtpSelfSigned
}
@ -257,19 +257,30 @@ function createMailer(callback) {
oldListeners.forEach(listener => module.exports.transport.on('idle', listener));
}
let lastCheck = Date.now();
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);
lastCheck = nextCheck;
if (checkDiff < throttling) {
log.verbose('Mail', 'Throttling next message in %s sec.', (throttling - checkDiff) / 1000);
setTimeout(next, throttling - checkDiff);
setTimeout(() => {
lastCheck = Date.now();
next();
}, throttling - checkDiff);
} else {
lastCheck = nextCheck;
next();
}
};

View file

@ -16,7 +16,7 @@ let _ = require('../translate')._;
let util = require('util');
let tableHelpers = require('../table-helpers');
let allowedKeys = ['description', 'from', 'address', 'reply_to', 'subject', 'editor_name', 'editor_data', 'template', 'source_url', 'list', 'segment', 'html', 'text', 'tracking_disabled'];
let allowedKeys = ['description', 'from', 'address', 'reply_to', 'subject', 'editor_name', 'editor_data', 'template', 'source_url', 'list', 'segment', 'html', 'text', 'click_tracking_disabled', 'open_tracking_disabled'];
module.exports.list = (start, limit, callback) => {
tableHelpers.list('campaigns', ['*'], 'scheduled', null, start, limit, callback);
@ -370,7 +370,8 @@ module.exports.create = (campaign, opts, callback) => {
campaign = tools.convertKeys(campaign);
let name = (campaign.name || '').toString().trim();
campaign.trackingDisabled = campaign.trackingDisabled ? 1 : 0;
campaign.openTrackingDisabled = campaign.openTrackingDisabled ? 1 : 0;
campaign.clickTrackingDisabled = campaign.clickTrackingDisabled ? 1 : 0;
opts = opts || {};
@ -592,7 +593,8 @@ module.exports.update = (id, updates, callback) => {
let campaign = tools.convertKeys(updates);
let name = (campaign.name || '').toString().trim();
campaign.trackingDisabled = campaign.trackingDisabled ? 1 : 0;
campaign.openTrackingDisabled = campaign.openTrackingDisabled ? 1 : 0;
campaign.clickTrackingDisabled = campaign.clickTrackingDisabled ? 1 : 0;
if (!name) {
return callback(new Error(_('Campaign Name must be set')));
@ -827,7 +829,7 @@ module.exports.reset = (id, callback) => {
return callback(err);
}
connection.query('UPDATE campaigns SET `status`=1, `status_change`=NULL, `delivered`=0, `opened`=0, `clicks`=0, `bounced`=0, `complained`=0, `unsubscribed`=0 WHERE id=? LIMIT 1', [id], err => {
connection.query('UPDATE campaigns SET `status`=1, `status_change`=NULL, `delivered`=0, `opened`=0, `clicks`=0, `bounced`=0, `complained`=0, `unsubscribed`=0, `blacklisted`=0 WHERE id=? LIMIT 1', [id], err => {
if (err) {
connection.release();
return callback(err);

View file

@ -42,7 +42,7 @@ module.exports.countClick = (remoteIp, useragent, campaignCid, listCid, subscrip
return callback(err);
}
if (!data || data.campaign.trackingDisabled) {
if (!data || data.campaign.clickTrackingDisabled) {
return callback(null, false);
}
@ -158,7 +158,7 @@ module.exports.countOpen = (remoteIp, useragent, campaignCid, listCid, subscript
return callback(err);
}
if (!data || data.campaign.trackingDisabled) {
if (!data || data.campaign.openTrackingDisabled) {
return callback(null, false);
}
@ -268,20 +268,13 @@ module.exports.add = (url, campaignId, callback) => {
};
module.exports.updateLinks = (campaign, list, subscription, serviceUrl, message, callback) => {
if (campaign.trackingDisabled || !message || !message.trim()) {
if ((campaign.openTrackingDisabled && campaign.clickTrackingDisabled) || !message || !message.trim()) {
// tracking is disabled, do not modify the message
return setImmediate(() => callback(null, message));
}
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();
// 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">';
@ -292,6 +285,20 @@ module.exports.updateLinks = (campaign, list, subscription, serviceUrl, message,
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,
@ -318,6 +325,7 @@ module.exports.updateLinks = (campaign, list, subscription, serviceUrl, message,
};
storeNext();
}
};
function getSubscriptionData(campaignCid, listCid, subscriptionCid, callback) {

View file

@ -148,7 +148,6 @@ function updateMenu(res) {
}
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)));

View file

@ -1,3 +1,3 @@
{
"schemaVersion": 28
"schemaVersion": 29
}

View file

@ -17,7 +17,7 @@
"sqldumptest": "NODE_ENV=test DUMP_NAME_SUFFIX=-test npm run sqldump",
"sqlresettest": "NODE_ENV=test npm run sqldrop && NODE_ENV=test npm run sqlinit",
"starttest": "NODE_ENV=test node index.js",
"_e2e": "NODE_ENV=test mocha test/e2e/index.js",
"_e2e": "NODE_ENV=test node test/e2e/index.js",
"e2e": "npm run sqlresettest && npm run _e2e"
},
"repository": {
@ -39,10 +39,10 @@
"grunt-contrib-nodeunit": "^1.0.0",
"grunt-eslint": "^19.0.0",
"jsxgettext-andris": "^0.9.0-patch.1",
"mailparser": "^2.0.5",
"mocha": "^3.3.0",
"phantomjs": "^2.1.7",
"selenium-webdriver": "^3.4.0"
"selenium-webdriver": "^3.4.0",
"url-pattern": "^1.0.3"
},
"optionalDependencies": {
"posix": "^4.1.1"
@ -70,6 +70,7 @@
"faker": "^4.1.0",
"feedparser": "^2.1.0",
"file-type": "^4.1.0",
"fs-extra": "^3.0.1",
"geoip-ultralight": "^0.1.5",
"gettext-parser": "^1.2.2",
"gm": "^1.23.0",
@ -84,6 +85,7 @@
"jsdom": "^9.12.0",
"juice": "^4.0.2",
"libmime": "^3.1.0",
"mailparser": "^2.0.5",
"marked": "^0.3.6",
"memory-cache": "^0.1.6",
"mjml": "3.3.0",

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,248 @@
.gjs-clm-tags .gjs-sm-title,
.gjs-sm-sector .gjs-sm-title {
border-top: none;
}
.gjs-clm-tags .gjs-clm-tag {
/* background-color: $tag-color; */
border: none;
box-shadow: none;
padding: 5px 8px;
text-shadow: none;
}
.gjs-field {
background-color: rgba(0, 0, 0, 0.15);
box-shadow: none;
}
.gjs-btnt.gjs-pn-active,
.gjs-pn-btn.gjs-pn-active {
box-shadow: none;
}
.gjs-pn-btn:hover {
color: rgba(255, 255, 255, 0.75);
}
.gjs-btnt.gjs-pn-active,
.gjs-color-active,
.gjs-pn-btn.gjs-pn-active,
.gjs-pn-btn:active,
.gjs-block:hover {
color: #f45e43; /* #f46d4c,#e4505d */
}
#gjs-rte-toolbar .gjs-rte-btn,
.gjs-btn-prim,
.gjs-btnt,
.gjs-clm-tags .gjs-sm-composite.gjs-clm-field,
.gjs-clm-tags .gjs-sm-field.gjs-sm-composite,
.gjs-clm-tags .gjs-sm-stack #gjs-sm-add,
.gjs-color-main,
.gjs-mdl-dialog,
.gjs-off-prv,
.gjs-pn-btn,
.gjs-pn-panel,
.gjs-sm-sector .gjs-sm-composite.gjs-clm-field,
.gjs-sm-sector .gjs-sm-field.gjs-sm-composite,
.gjs-sm-sector .gjs-sm-stack #gjs-sm-add {
color: #888686;
}
#gjs-rte-toolbar,
.gjs-bg-main,
.gjs-clm-select option,
.gjs-clm-tags .gjs-sm-colorp-c,
.gjs-editor,
.gjs-mdl-dialog,
.gjs-nv-item .gjs-nv-title-c,
.gjs-off-prv,
.gjs-pn-panel,
.gjs-select option,
.gjs-sm-sector .gjs-sm-colorp-c,
.gjs-sm-select option,
.gjs-sm-unit option,
.sp-container,
.gjs-block {
background-color: #2c2e35;
}
.gjs-import-label,
.gjs-export-label {
margin-bottom: 10px;
font-size: 13px;
}
.gjs-mdl-dialog .gjs-btn-import {
margin-top: 10px;
}
.CodeMirror {
border-radius: 3px;
height: 450px;
font-family: sans-serif, monospace;
letter-spacing: 0.3px;
font-size: 12px;
}
/* Extra */
.gjs-block {
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 3px;
margin: 10px 2.5% 5px;
box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s ease 0s;
}
.gjs-block:hover {
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
}
#gjs-pn-views-container.gjs-pn-panel {
padding: 39px 0 0;
}
#gjs-pn-views.gjs-pn-panel {
padding: 0;
border: none;
}
#gjs-pn-views .gjs-pn-btn {
margin: 0;
height: 40px;
padding: 10px;
width: 25%;
border-bottom: 2px solid rgba(0, 0, 0, 0.3);
}
#gjs-pn-views .gjs-pn-active {
/*
color: rgba(255, 255, 255, 0.9);
border-bottom: 2px solid #f45e43;
*/
border-radius: 0;
}
#gjs-pn-devices-c {
padding-left: 30px;
}
#gjs-pn-options {
padding-right: 30px;
}
.gjs-sm-composite .gjs-sm-properties {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
}
#gjs-sm-border-top-left-radius,
#gjs-sm-border-top-right-radius,
#gjs-sm-border-bottom-left-radius,
#gjs-sm-border-bottom-right-radius,
#gjs-sm-margin-top,
#gjs-sm-margin-bottom,
#gjs-sm-margin-right,
#gjs-sm-margin-left,
#gjs-sm-padding-top,
#gjs-sm-padding-bottom,
#gjs-sm-padding-right,
#gjs-sm-padding-left {
flex: 999 1 60px;
}
#gjs-sm-border-width,
#gjs-sm-border-style,
#gjs-sm-border-color {
flex: 999 1 80px;
}
#gjs-sm-margin-left,
#gjs-sm-padding-left {
order: 2;
}
#gjs-sm-margin-right,
#gjs-sm-padding-right {
order: 3;
}
#gjs-sm-margin-bottom,
#gjs-sm-padding-bottom {
order: 4;
}
.gjs-field-radio {
width: 100%;
}
.gjs-field-radio #gjs-sm-input-holder {
display: flex;
}
.gjs-radio-item {
flex: 1 0 auto;
text-align: center;
}
.gjs-sm-sector .gjs-sm-property.gjs-sm-list {
width: 50%;
}
.gjs-mdl-content {
border-top: none;
}
.gjs-sm-sector .gjs-sm-property .gjs-sm-layer.gjs-sm-active {
background-color: rgba(255, 255, 255, 0.09);
}
/*
#gjs-pn-views-container,
#gjs-pn-views{
min-width: 270px;
}
*/
.gjs-f-button::before { content: 'B'; }
.gjs-f-divider::before { content: 'D'; }
.gjs-mdl-dialog-sm {
width: 300px;
}
.gjs-mdl-dialog form .gjs-sm-property {
font-size: 12px;
margin-bottom: 15px;
}
.gjs-mdl-dialog form .gjs-sm-label {
margin-bottom: 5px;
}
.anim-spin {
animation: 0.5s linear 0s normal none infinite running spin;
}
.form-status {
float: right;
font-size: 14px;
}
.text-danger {
color: #f92929;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View file

@ -54,14 +54,13 @@
.gjs-nv-item .gjs-nv-title-c,
.gjs-off-prv,
.gjs-pn-panel,
.gjs-block,
.gjs-select option,
.gjs-sm-sector .gjs-sm-colorp-c,
.gjs-sm-select option,
.gjs-sm-unit option,
.sp-container,
.gjs-block {
background-color: #373d49;
}
.sp-container {
background-color: #373d49; }
.gjs-import-label,
.gjs-export-label {
@ -79,18 +78,15 @@
font-size: 12px; }
/* Extra */
.gjs-block {
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 3px;
margin: 10px 2.5% 5px;
box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s ease 0s;
}
transition: box-shadow, color 0.2s ease 0s; }
.gjs-block:hover {
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
}
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15); }
#gjs-pn-views-container.gjs-pn-panel {
padding: 39px 0 0; }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,124 @@
grapesjs.plugins.add('gjs-preset-mjml', (editor, opts) => {
var opt = opts || {};
var config = editor.getConfig();
config.showDevices = 0;
var updateTooltip = function(coll, pos) {
coll.each(function(item) {
var attrs = item.get('attributes');
attrs['data-tooltip-pos'] = pos || 'bottom';
item.set('attributes', attrs);
});
}
/****************** COMMANDS *************************/
var cmdm = editor.Commands;
cmdm.add('undo', {
run: function(editor, sender) {
sender.set('active', 0);
editor.UndoManager.undo(1);
}
});
cmdm.add('redo', {
run: function(editor, sender) {
sender.set('active', 0);
editor.UndoManager.redo(1);
}
});
cmdm.add('set-device-desktop', {
run: function(editor) {
editor.setDevice('Desktop');
}
});
cmdm.add('set-device-mobile', {
run: function(editor) {
editor.setDevice('Mobile');
}
});
cmdm.add('clean-all', {
run: function(editor, sender) {
sender && sender.set('active',false);
if (confirm('Are you sure you want to clean the canvas?')) {
editor.setComponents('<mj-container><mj-section><mj-column>'+
'<mj-text>Start from here</mj-text></mj-column></mj-section></mj-container>');
localStorage.setItem('gjs-mjml-css', '');
localStorage.setItem('gjs-mjml-html', '');
}
}
});
/****************** BUTTONS *************************/
var pnm = editor.Panels;
pnm.addButton('options', [{
id: 'undo',
className: 'fa fa-undo icon-undo',
command: 'undo',
attributes: { title: 'Undo (CTRL/CMD + Z)'}
},{
id: 'redo',
className: 'fa fa-repeat icon-redo',
command: 'redo',
attributes: { title: 'Redo (CTRL/CMD + SHIFT + Z)' }
},{
id: 'clean-all',
className: 'fa fa-trash icon-blank',
command: 'clean-all',
attributes: { title: 'Empty canvas' }
}]);
// Add devices buttons
var panelDevices = pnm.addPanel({id: 'devices-c'});
var deviceBtns = panelDevices.get('buttons');
deviceBtns.add([{
id: 'deviceDesktop',
command: 'set-device-desktop',
className: 'fa fa-desktop',
attributes: {'title': 'Desktop'},
active: 1,
},{
id: 'deviceMobile',
command: 'set-device-mobile',
className: 'fa fa-mobile',
attributes: {'title': 'Mobile'},
}]);
// Remove preview and code button
let prvBtn = pnm.addButton('options', 'preview');
let optPanel = pnm.getPanel('options');
let cmdBtns = optPanel.get('buttons');
prvBtn && cmdBtns.remove(prvBtn);
updateTooltip(deviceBtns);
updateTooltip(pnm.getPanel('options').get('buttons'));
updateTooltip(pnm.getPanel('options').get('buttons'));
updateTooltip(pnm.getPanel('views').get('buttons'));
/****************** EVENTS *************************/
// On component change show the Style Manager
editor.on('change:selectedComponent', function() {
var openLayersBtn = editor.Panels.getButton('views', 'open-layers');
// Don't switch when the Layer Manager is on or
// there is no selected component
if((!openLayersBtn || !openLayersBtn.get('active')) &&
editor.editor.get('selectedComponent')) {
var openSmBtn = editor.Panels.getButton('views', 'open-sm');
openSmBtn && openSmBtn.set('active', 1);
}
});
// Do stuff on load
editor.on('load', function() {
// Open block manager
var openBlocksBtn = editor.Panels.getButton('views', 'open-blocks');
openBlocksBtn && openBlocksBtn.set('active', 1);
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,9 @@
#### Image Credits
All images CC0. Thanks to the Pixabay community.
* [bird-1.jpg](https://pixabay.com/de/zoo-pfau-kopf-tier-federn-vogel-866181/)
* [bird-2.jpg](https://pixabay.com/de/möwe-himmel-urlaub-vogel-schnabel-249638/)
* [bird-3.jpg](https://pixabay.com/de/porträt-vogel-natur-wild-räuber-1072696/)
* [bird-4.jpg](https://pixabay.com/de/goldener-fasan-vogel-exotische-317503/)
* [clouds.jpg](https://pixabay.com/de/luft-atmosphäre-blau-klar-klima-2716/)

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View file

@ -0,0 +1,86 @@
<mjml>
<mj-head>
<mj-title>Hello World</mj-title>
</mj-head>
<mj-body>
<mj-container>
<!-- Company Header -->
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="italic" font-size="20" color="#626262">
My Company
</mj-text>
</mj-column>
</mj-section>
<!-- Image Header -->
<mj-section background-url="./images/clouds.jpg" background-size="cover" background-repeat="no-repeat">
<mj-column>
<mj-text align="center" color="#fff" font-size="40" font-family="Helvetica Neue" padding-top="30px" padding-bottom="30px">Slogan here</mj-text>
<mj-button background-color="#F63A4D" href="#">
Promotion
</mj-button>
</mj-column>
</mj-section>
<!-- Intro text -->
<mj-section background-color="#fafafa">
<mj-column width="400">
<mj-text font-style="italic" font-size="20" font-family="Helvetica Neue" color="#cc2d2d">Attention!</mj-text>
<mj-text color="#525252">
The MJML Mode is currently experimental and there're a few bugs you should avoid.
<ul>
<li>Don't toggle visibility in the layer manager.</li>
<li>Don't import the mj-head if you import a custom template.</li>
<li>Don't duplicate mj-image as the duplicate is missing the src attribute on export. This bug is probably not limited to mj-image.</li>
<li>Don't use % values.</li>
</ul>
It's generally working great and very promising. Thanks to @artf for making this all possible.
</mj-text>
<mj-button background-color="#F45E43" href="https://github.com/artf/grapesjs-mjml">Learn more about GrapesJS MJML</mj-button>
</mj-column>
</mj-section>
<!-- Side image -->
<mj-section background-color="white">
<mj-column>
<mj-image src="./images/bird-1.jpg" />
</mj-column>
<mj-column>
<mj-text font-style="italic" font-size="20" font-family="Helvetica Neue" color="#626262">
Amazing Birds
</mj-text>
<mj-text color="#525252">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin rutrum enim eget magna efficitur, eu semper augue semper. Aliquam erat volutpat. Cras id dui lectus. Vestibulum sed finibus lectus.</mj-text>
</mj-column>
</mj-section>
<!-- Icons -->
<mj-section background-color="#fbfbfb">
<mj-column>
<mj-image width="200" src="./images/bird-2.jpg" />
</mj-column>
<mj-column>
<mj-image width="200" src="./images/bird-3.jpg" />
</mj-column>
<mj-column>
<mj-image width="200" src="./images/bird-4.jpg" />
</mj-column>
</mj-section>
<!-- Footer -->
<mj-section background-color="#e7e7e7">
<mj-column>
<mj-text color="#525252">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin rutrum enim eget magna efficitur, eu semper augue semper. Aliquam erat volutpat. Cras id dui lectus. Vestibulum sed finibus lectus, sit amet suscipit nibh. Proin nec commodo purus. Sed eget
nulla elit. Nulla aliquet mollis faucibus.
</mj-text>
<mj-button href="#">Hello There!</mj-button>
<mj-social display="facebook twitter" />
</mj-column>
</mj-section>
</mj-container>
</mj-body>
</mjml>

View file

@ -1,12 +1,13 @@
'use strict';
let config = require('config');
let express = require('express');
let router = new express.Router();
let passport = require('../lib/passport');
let fs = require('fs');
let path = require('path');
let editorHelpers = require('../lib/editor-helpers.js')
const config = require('config');
const express = require('express');
const router = new express.Router();
const passport = require('../lib/passport');
const _ = require('../lib/translate')._;
const fs = require('fs');
const path = require('path');
const editorHelpers = require('../lib/editor-helpers')
router.all('/*', (req, res, next) => {
if (!req.user) {
@ -23,28 +24,38 @@ router.get('/editor', passport.csrfProtection, (req, res) => {
return res.redirect('/');
}
resource.editorName = resource.editorName ||  'grapejs';
resource.editorData = !resource.editorData ?
{
template: req.query.template || 'demo'
} :
JSON.parse(resource.editorData);
if (!resource.html && !resource.editorData.html) {
try {
let file = path.join(__dirname, '..', 'public', 'grapejs', 'templates', resource.editorData.template, 'index.html');
resource.html = fs.readFileSync(file, 'utf8');
resource.editorData = JSON.parse(resource.editorData);
} catch (err) {
resource.editorData = {
template: req.query.template || 'demo'
}
}
if (!resource.html && !resource.editorData.html && !resource.editorData.mjml) {
const base = path.join(__dirname, '..', 'public', 'grapejs', 'templates', resource.editorData.template);
try {
resource.editorData.mjml = fs.readFileSync(path.join(base, 'index.mjml'), 'utf8');
} catch (err) {
try {
resource.html = fs.readFileSync(path.join(base, 'index.html'), 'utf8');
} catch (err) {
resource.html = err.message || err;
}
}
}
res.render('grapejs/editor', {
layout: 'grapejs/layout-editor',
type: req.query.type,
stringifiedResource: JSON.stringify(resource),
resource,
editorConfig: config.grapejs,
csrfToken: req.csrfToken(),
editor: {
name: resource.editorName || 'grapejs',
mode: resource.editorData.mjml ? 'mjml' : 'html',
config: config.grapejs
},
csrfToken: req.csrfToken()
});
});
});

View file

@ -702,7 +702,12 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next)
}
if (req.query.formTest ||
const autoUnsubscribe = req.query.auto === 'yes';
if (autoUnsubscribe) {
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
} else if (req.query.formTest ||
list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM ||
list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
@ -741,7 +746,7 @@ router.get('/:lcid/unsubscribe/:ucid', passport.csrfProtection, (req, res, next)
});
});
} else { // UnsubscriptionMode.ONE_STEP || UnsubscriptionMode.TWO_STEP || UnsubscriptionMode.MANUAL
handleUnsubscribe(list, subscription, req.query.c, req.ip, res, next);
handleUnsubscribe(list, subscription, autoUnsubscribe, req.query.c, req.ip, res, next);
}
});
});
@ -771,14 +776,32 @@ router.post('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, (
return next(err);
}
handleUnsubscribe(list, subscription, campaignId, req.ip, res, next);
handleUnsubscribe(list, subscription, false, campaignId, req.ip, res, next);
});
});
});
function handleUnsubscribe(list, subscription, campaignId, ip, res, next) {
if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP ||
list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
function handleUnsubscribe(list, subscription, autoUnsubscribe, campaignId, ip, res, next) {
if ((list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) ||
(autoUnsubscribe && (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM)) ) {
subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
return next(err);
}
// TODO: Shall we do anything with "found"?
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
});
});
} else if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
const data = {
subscriptionId: subscription.id,
@ -799,24 +822,6 @@ function handleUnsubscribe(list, subscription, campaignId, ip, res, next) {
});
});
} else if (list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP ||
list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) {
subscriptions.changeStatus(list.id, subscription.id, campaignId, subscriptions.Status.UNSUBSCRIBED, (err, found) => {
if (err) {
return next(err);
}
// TODO: Shall we do anything with "found"?
mailHelpers.sendUnsubscriptionConfirmed(list, subscription.email, subscription, err => {
if (err) {
return next(err);
}
res.redirect('/subscription/' + list.cid + '/unsubscribed-notice');
});
});
} else { // UnsubscriptionMode.MANUAL
res.redirect('/subscription/' + list.cid + '/manual-unsubscribe-notice');
}

View file

@ -466,12 +466,20 @@ let sendLoop = () => {
return setTimeout(sendLoop, 10 * 1000);
}
let isThrottled = false;
let getNext = () => {
if (!mailer.transport.isIdle()) {
if (!mailer.transport.isIdle() || isThrottled) {
// only retrieve new messages if there are free slots in the mailer queue
return;
}
isThrottled = true;
mailer.transport.checkThrottling(() => {
isThrottled = false;
// find an unsent message
findUnsent((err, message) => {
if (err) {
@ -499,6 +507,7 @@ let sendLoop = () => {
setTimeout(getNext, mailing_timeout);
return;
}
if (!blacklisted) {
let tryCount = 0;
let trySend = () => {
@ -571,14 +580,15 @@ let sendLoop = () => {
});
});
}
setImmediate(() => mailer.transport.checkThrottling(getNext));
setImmediate(getNext);
});
});
});
});
};
mailer.transport.on('idle', () => mailer.transport.checkThrottling(getNext));
setImmediate(() => mailer.transport.checkThrottling(getNext));
mailer.transport.on('idle', getNext);
setImmediate(getNext);
});
};

View file

@ -12,7 +12,7 @@ set -e
yum -y install epel-release
curl --silent --location https://rpm.nodesource.com/setup_6.x | bash -
curl --silent --location https://rpm.nodesource.com/setup_7.x | bash -
yum -y install mariadb-server nodejs ImageMagick git python redis pwgen bind-utils gcc-c++ make
systemctl start mariadb
@ -225,4 +225,3 @@ systemctl start zone-mta.service
systemctl start mailtrain.service
echo "Success! Open http://$HOSTNAME/ and log in as admin:test";

View file

@ -12,7 +12,7 @@ set -e
export DEBIAN_FRONTEND=noninteractive
curl -sL https://deb.nodesource.com/setup_6.x | bash -
curl -sL https://deb.nodesource.com/setup_7.x | bash -
apt-get -q -y install mariadb-server pwgen nodejs imagemagick git ufw build-essential dnsutils python software-properties-common
apt-add-repository -y ppa:chris-lea/redis-server

View file

@ -69,7 +69,6 @@ CREATE TABLE `campaigns` (
`html_prepared` longtext,
`text` longtext,
`status` tinyint(4) unsigned NOT NULL DEFAULT '1',
`tracking_disabled` tinyint(4) unsigned NOT NULL DEFAULT '0',
`scheduled` timestamp NULL DEFAULT NULL,
`status_change` timestamp NULL DEFAULT NULL,
`delivered` int(11) unsigned NOT NULL DEFAULT '0',
@ -80,6 +79,8 @@ CREATE TABLE `campaigns` (
`bounced` int(1) unsigned NOT NULL DEFAULT '0',
`complained` int(1) unsigned NOT NULL DEFAULT '0',
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`open_tracking_disabled` tinyint(4) unsigned NOT NULL DEFAULT '0',
`click_tracking_disabled` tinyint(4) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `cid` (`cid`),
KEY `name` (`name`(191)),
@ -93,8 +94,8 @@ CREATE TABLE `confirmations` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` varchar(255) CHARACTER SET ascii NOT NULL,
`list` int(11) unsigned NOT NULL,
`email` varchar(255) NOT NULL,
`opt_in_ip` varchar(100) DEFAULT NULL,
`action` varchar(100) NOT NULL,
`ip` varchar(100) DEFAULT NULL,
`data` text NOT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@ -193,11 +194,17 @@ CREATE TABLE `lists` (
`subscribers` int(11) unsigned DEFAULT '0',
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`public_subscribe` tinyint(1) unsigned NOT NULL DEFAULT '1',
`unsubscription_mode` int(11) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `cid` (`cid`),
KEY `name` (`name`(191))
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
INSERT INTO `lists` (`id`, `cid`, `default_form`, `name`, `description`, `subscribers`, `created`, `public_subscribe`) VALUES (1,'Hkj1vCoJb',NULL,'01 Testlist - Public Subscribe','',0,NOW(),1);
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4;
INSERT INTO `lists` (`id`, `cid`, `default_form`, `name`, `description`, `subscribers`, `created`, `public_subscribe`, `unsubscription_mode`) VALUES (1,'Hkj1vCoJb',0,'#1 (one-step, no form)','',0,NOW(),1,0);
INSERT INTO `lists` (`id`, `cid`, `default_form`, `name`, `description`, `subscribers`, `created`, `public_subscribe`, `unsubscription_mode`) VALUES (2,'SktV4HDZ-',NULL,'#2 (one-step, with form)','',0,NOW(),1,1);
INSERT INTO `lists` (`id`, `cid`, `default_form`, `name`, `description`, `subscribers`, `created`, `public_subscribe`, `unsubscription_mode`) VALUES (3,'BkdvNBw-W',NULL,'#3 (two-step, no form)','',0,NOW(),1,2);
INSERT INTO `lists` (`id`, `cid`, `default_form`, `name`, `description`, `subscribers`, `created`, `public_subscribe`, `unsubscription_mode`) VALUES (4,'rJMKVrDZ-',NULL,'#4 (two-step, with form)','',0,NOW(),1,3);
INSERT INTO `lists` (`id`, `cid`, `default_form`, `name`, `description`, `subscribers`, `created`, `public_subscribe`, `unsubscription_mode`) VALUES (5,'SJgoNSw-W',NULL,'#5 (manual unsubscribe)','',0,NOW(),1,4);
INSERT INTO `lists` (`id`, `cid`, `default_form`, `name`, `description`, `subscribers`, `created`, `public_subscribe`, `unsubscription_mode`) VALUES (6,'HyveEPvWW',NULL,'#6 (non-public)','',0,NOW(),0,0);
CREATE TABLE `queued` (
`campaign` int(11) unsigned NOT NULL,
`list` int(11) unsigned NOT NULL,
@ -269,7 +276,7 @@ CREATE TABLE `settings` (
`value` text NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `key` (`key`)
) ENGINE=InnoDB AUTO_INCREMENT=112 DEFAULT CHARSET=utf8mb4;
) ENGINE=InnoDB AUTO_INCREMENT=114 DEFAULT CHARSET=utf8mb4;
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (1,'smtp_hostname','localhost');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (2,'smtp_port','5587');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (3,'smtp_encryption','NONE');
@ -286,7 +293,7 @@ INSERT INTO `settings` (`id`, `key`, `value`) VALUES (13,'default_from','My Awes
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (14,'default_address','admin@example.com');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (15,'default_subject','Test message');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (16,'default_homepage','https://mailtrain.org');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','27');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (17,'db_schema_version','29');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (46,'ua_code','');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (47,'shoutout','');
INSERT INTO `settings` (`id`, `key`, `value`) VALUES (54,'mail_transport','smtp');
@ -361,6 +368,146 @@ CREATE TABLE `subscription__1` (
KEY `latest_click` (`latest_click`),
KEY `created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `subscription__2` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` varchar(255) CHARACTER SET ascii NOT NULL,
`email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '',
`opt_in_ip` varchar(100) DEFAULT NULL,
`opt_in_country` varchar(2) DEFAULT NULL,
`tz` varchar(100) CHARACTER SET ascii DEFAULT NULL,
`imported` int(11) unsigned DEFAULT NULL,
`status` tinyint(4) unsigned NOT NULL DEFAULT '1',
`is_test` tinyint(4) unsigned NOT NULL DEFAULT '0',
`status_change` timestamp NULL DEFAULT NULL,
`latest_open` timestamp NULL DEFAULT NULL,
`latest_click` timestamp NULL DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
UNIQUE KEY `cid` (`cid`),
KEY `status` (`status`),
KEY `first_name` (`first_name`(191)),
KEY `last_name` (`last_name`(191)),
KEY `subscriber_tz` (`tz`),
KEY `is_test` (`is_test`),
KEY `latest_open` (`latest_open`),
KEY `latest_click` (`latest_click`),
KEY `created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `subscription__3` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` varchar(255) CHARACTER SET ascii NOT NULL,
`email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '',
`opt_in_ip` varchar(100) DEFAULT NULL,
`opt_in_country` varchar(2) DEFAULT NULL,
`tz` varchar(100) CHARACTER SET ascii DEFAULT NULL,
`imported` int(11) unsigned DEFAULT NULL,
`status` tinyint(4) unsigned NOT NULL DEFAULT '1',
`is_test` tinyint(4) unsigned NOT NULL DEFAULT '0',
`status_change` timestamp NULL DEFAULT NULL,
`latest_open` timestamp NULL DEFAULT NULL,
`latest_click` timestamp NULL DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
UNIQUE KEY `cid` (`cid`),
KEY `status` (`status`),
KEY `first_name` (`first_name`(191)),
KEY `last_name` (`last_name`(191)),
KEY `subscriber_tz` (`tz`),
KEY `is_test` (`is_test`),
KEY `latest_open` (`latest_open`),
KEY `latest_click` (`latest_click`),
KEY `created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `subscription__4` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` varchar(255) CHARACTER SET ascii NOT NULL,
`email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '',
`opt_in_ip` varchar(100) DEFAULT NULL,
`opt_in_country` varchar(2) DEFAULT NULL,
`tz` varchar(100) CHARACTER SET ascii DEFAULT NULL,
`imported` int(11) unsigned DEFAULT NULL,
`status` tinyint(4) unsigned NOT NULL DEFAULT '1',
`is_test` tinyint(4) unsigned NOT NULL DEFAULT '0',
`status_change` timestamp NULL DEFAULT NULL,
`latest_open` timestamp NULL DEFAULT NULL,
`latest_click` timestamp NULL DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
UNIQUE KEY `cid` (`cid`),
KEY `status` (`status`),
KEY `first_name` (`first_name`(191)),
KEY `last_name` (`last_name`(191)),
KEY `subscriber_tz` (`tz`),
KEY `is_test` (`is_test`),
KEY `latest_open` (`latest_open`),
KEY `latest_click` (`latest_click`),
KEY `created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `subscription__5` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` varchar(255) CHARACTER SET ascii NOT NULL,
`email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '',
`opt_in_ip` varchar(100) DEFAULT NULL,
`opt_in_country` varchar(2) DEFAULT NULL,
`tz` varchar(100) CHARACTER SET ascii DEFAULT NULL,
`imported` int(11) unsigned DEFAULT NULL,
`status` tinyint(4) unsigned NOT NULL DEFAULT '1',
`is_test` tinyint(4) unsigned NOT NULL DEFAULT '0',
`status_change` timestamp NULL DEFAULT NULL,
`latest_open` timestamp NULL DEFAULT NULL,
`latest_click` timestamp NULL DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
UNIQUE KEY `cid` (`cid`),
KEY `status` (`status`),
KEY `first_name` (`first_name`(191)),
KEY `last_name` (`last_name`(191)),
KEY `subscriber_tz` (`tz`),
KEY `is_test` (`is_test`),
KEY `latest_open` (`latest_open`),
KEY `latest_click` (`latest_click`),
KEY `created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `subscription__6` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` varchar(255) CHARACTER SET ascii NOT NULL,
`email` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '',
`opt_in_ip` varchar(100) DEFAULT NULL,
`opt_in_country` varchar(2) DEFAULT NULL,
`tz` varchar(100) CHARACTER SET ascii DEFAULT NULL,
`imported` int(11) unsigned DEFAULT NULL,
`status` tinyint(4) unsigned NOT NULL DEFAULT '1',
`is_test` tinyint(4) unsigned NOT NULL DEFAULT '0',
`status_change` timestamp NULL DEFAULT NULL,
`latest_open` timestamp NULL DEFAULT NULL,
`latest_click` timestamp NULL DEFAULT NULL,
`created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
UNIQUE KEY `cid` (`cid`),
KEY `status` (`status`),
KEY `first_name` (`first_name`(191)),
KEY `last_name` (`last_name`(191)),
KEY `subscriber_tz` (`tz`),
KEY `is_test` (`is_test`),
KEY `latest_open` (`latest_open`),
KEY `latest_click` (`latest_click`),
KEY `created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `templates` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL DEFAULT '',
@ -422,14 +569,14 @@ INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/blantyre',120);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/brazzaville',60);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/bujumbura',120);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/cairo',120);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/casablanca',60);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/casablanca',0);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/ceuta',120);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/conakry',0);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/dakar',0);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/dar_es_salaam',180);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/djibouti',180);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/douala',60);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/el_aaiun',60);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/el_aaiun',0);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/freetown',0);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/gaborone',120);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('africa/harare',120);
@ -603,7 +750,7 @@ INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rio_branco',-300);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/rosario',-180);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santarem',-180);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santa_isabel',-420);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santiago',-180);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santiago',-240);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/santo_domingo',-240);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/sao_paulo',-180);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('america/scoresbysund',0);
@ -788,8 +935,8 @@ INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/pacific',-420);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/saskatchewan',-360);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('canada/yukon',-420);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('cet',120);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('chile/continental',-180);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('chile/easterisland',-300);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('chile/continental',-240);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('chile/easterisland',-360);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('cst6cdt',-300);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('cuba',-240);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('eet',180);
@ -936,7 +1083,7 @@ INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/auckland',720);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/bougainville',660);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/chatham',765);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/chuuk',600);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/easter',-300);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/easter',-360);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/efate',660);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/enderbury',780);
INSERT INTO `tzoffset` (`tz`, `offset`) VALUES ('pacific/fakaofo',780);

View file

@ -0,0 +1,13 @@
# Header section
# Define incrementing schema version number
SET @schema_version = '29';
# Rename column tracking_disabled
ALTER TABLE `campaigns` ADD COLUMN `open_tracking_disabled` tinyint(4) unsigned DEFAULT 0 NOT NULL, ADD COLUMN `click_tracking_disabled` tinyint(4) unsigned DEFAULT 0 NOT NULL;
UPDATE `campaigns` SET `open_tracking_disabled` = `tracking_disabled`, `click_tracking_disabled` = `tracking_disabled`;
ALTER TABLE `campaigns` DROP COLUMN `tracking_disabled`;
# 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

@ -2,8 +2,9 @@
"parser": "babel-eslint",
"rules": {
"strict": 0,
"no-invalid-this": 0,
"no-unused-expressions": 0
"no-console": 0,
"comma-dangle": 0,
"arrow-body-style": 0
},
"env": {
"mocha": true

View file

@ -1,31 +0,0 @@
'use strict';
const config = require('config');
module.exports = {
app: config,
baseUrl: 'http://localhost:' + config.www.port,
users: {
admin: {
username: 'admin',
password: 'test'
}
},
lists: {
one: {
id: 1,
cid: 'Hkj1vCoJb',
publicSubscribe: 1,
unsubscriptionMode: 0
}
},
settings: {
'service-url' : 'http://localhost:' + config.www.port + '/',
'default-homepage': 'https://mailtrain.org',
'smtp-hostname': config.testserver.host,
'smtp-port': config.testserver.port,
'smtp-encryption': 'NONE',
'smtp-user': config.testserver.username,
'smtp-pass': config.testserver.password
}
};

View file

@ -1,15 +0,0 @@
'use strict';
const config = require('./config');
const webdriver = require('selenium-webdriver');
const driver = new webdriver.Builder()
.forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs')
.build();
if (global.USE_SHARED_DRIVER === true) {
driver.originalQuit = driver.quit;
driver.quit = () => {};
}
module.exports = driver;

View file

@ -1,36 +1,28 @@
'use strict';
require('./helpers/exit-unless-test');
require('./lib/exit-unless-test');
const mocha = require('./lib/mocha-e2e').mocha;
const path = require('path');
global.USE_SHARED_DRIVER = true;
const driver = require('./helpers/driver');
const only = 'only';
const skip = 'skip';
let tests = [
['tests/login'],
['tests/subscription']
'login',
'subscription'
];
tests = tests.filter(t => t[1] !== skip);
if (tests.some(t => t[1] === only)) {
tests = tests.filter(t => t[1] === only);
tests = tests.map(testSpec => (testSpec.constructor === Array ? testSpec : [testSpec]));
tests = tests.filter(testSpec => testSpec[1] !== skip);
if (tests.some(testSpec => testSpec[1] === only)) {
tests = tests.filter(testSpec => testSpec[1] === only);
}
describe('e2e', function() {
this.timeout(10000);
for (const testSpec of tests) {
const testPath = path.join(__dirname, 'tests', testSpec[0] + '.js');
mocha.addFile(testPath);
}
tests.forEach(t => {
describe(t[0], () => {
require('./' + t[0]); // eslint-disable-line global-require
});
});
after(() => driver.originalQuit());
mocha.run(failures => {
process.exit(failures); // exit with non-zero status if there were failures
});

63
test/e2e/lib/config.js Normal file
View file

@ -0,0 +1,63 @@
'use strict';
const config = require('config');
module.exports = {
app: config,
baseUrl: 'http://localhost:' + config.www.port,
mailUrl: 'http://localhost:' + config.testserver.mailboxserverport,
users: {
admin: {
username: 'admin',
password: 'test'
}
},
lists: {
l1: {
id: 1,
cid: 'Hkj1vCoJb',
publicSubscribe: 1,
unsubscriptionMode: 0, // (one-step, no form)
},
l2: {
id: 2,
cid: 'SktV4HDZ-',
publicSubscribe: 1,
unsubscriptionMode: 1, // (one-step, with form)
},
l3: {
id: 3,
cid: 'BkdvNBw-W',
publicSubscribe: 1,
unsubscriptionMode: 2, // (two-step, no form)
},
l4: {
id: 4,
cid: 'rJMKVrDZ-',
publicSubscribe: 1,
unsubscriptionMode: 3, // (two-step, with form)
},
l5: {
id: 5,
cid: 'SJgoNSw-W',
publicSubscribe: 1,
unsubscriptionMode: 4, // (manual unsubscribe)
},
l6: {
id: 6,
cid: 'HyveEPvWW',
publicSubscribe: 0,
unsubscriptionMode: 0, // (one-step, no form)
}
},
settings: {
'service-url': 'http://localhost:' + config.www.port + '/',
'admin-email': 'admin@example.com',
'default-homepage': 'https://mailtrain.org',
'smtp-hostname': config.testserver.host,
'smtp-port': config.testserver.port,
'smtp-encryption': 'NONE',
'smtp-user': config.testserver.username,
'smtp-pass': config.testserver.password
}
};

19
test/e2e/lib/mail.js Normal file
View file

@ -0,0 +1,19 @@
'use strict';
const config = require('./config');
const driver = require('./mocha-e2e').driver;
const page = require('./page');
module.exports = (...extras) => page({
async fetchMail(address) {
await driver.sleep(1000);
await driver.navigate().to(`${config.mailUrl}/${address}`);
await this.waitUntilVisible();
},
async ensureUrl() {
throw new Error('Unsupported method.');
}
}, ...extras);

213
test/e2e/lib/mocha-e2e.js Normal file
View file

@ -0,0 +1,213 @@
'use strict';
/* eslint-disable no-console */
const Mocha = require('mocha');
const color = Mocha.reporters.Base.color;
const WorkerCounter = require('./worker-counter');
const fs = require('fs-extra');
const config = require('./config');
const webdriver = require('selenium-webdriver');
const driver = new webdriver.Builder()
.forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs')
.build();
const failHandlerRunning = new WorkerCounter();
function UseCaseReporter(runner) {
Mocha.reporters.Base.call(this, runner);
const self = this;
let indents = 0;
function indent () {
return Array(indents).join(' ');
}
runner.on('start', () => {
console.log();
});
runner.on('suite', suite => {
++indents;
console.log(color('suite', '%s%s'), indent(), suite.title);
});
runner.on('suite end', () => {
--indents;
if (indents === 1) {
console.log();
}
});
runner.on('use-case', useCase => {
++indents;
console.log();
console.log(color('suite', '%sUse case: %s'), indent(), useCase.title);
});
runner.on('use-case end', () => {
--indents;
});
runner.on('steps', useCase => {
++indents;
console.log(color('pass', '%s%s'), indent(), useCase.title);
});
runner.on('steps end', () => {
--indents;
});
runner.on('step pass', step => {
console.log(indent() + color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) + color('pass', ' %s'), step.title);
});
runner.on('step fail', step => {
console.log(indent() + color('fail', ' %s'), step.title);
});
runner.on('pending', test => {
const fmt = indent() + color('pending', ' - %s');
console.log(fmt, test.title);
});
runner.on('pass', test => {
let fmt;
if (test.speed === 'fast') {
fmt = indent() +
color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) +
color('pass', ' %s');
console.log(fmt, test.title);
} else {
fmt = indent() +
color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) +
color('pass', ' %s') +
color(test.speed, ' (%dms)');
console.log(fmt, test.title, test.duration);
}
});
runner.on('fail', (test, err) => {
failHandlerRunning.enter();
(async () => {
const currentUrl = await driver.getCurrentUrl();
const info = `URL: ${currentUrl}`;
await fs.writeFile('last-failed-e2e-test.info', info);
await fs.writeFile('last-failed-e2e-test.html', await driver.getPageSource());
await fs.writeFile('last-failed-e2e-test.png', new Buffer(await driver.takeScreenshot(), 'base64'));
failHandlerRunning.exit();
})();
console.log(indent() + color('fail', ' %s'), test.title);
console.log();
console.log(err);
console.log();
console.log('Snaphot of and info about the current page are in last-failed-e2e-test.*');
});
runner.on('end', () => {
const stats = self.stats;
let fmt;
console.log();
// passes
fmt = color('bright pass', ' ') + color('green', ' %d passing');
console.log(fmt, stats.passes);
// pending
if (stats.pending) {
fmt = color('pending', ' ') + color('pending', ' %d pending');
console.log(fmt, stats.pending);
}
// failures
if (stats.failures) {
fmt = color('fail', ' %d failing');
console.log(fmt, stats.failures);
}
console.log();
});
}
const mocha = new Mocha()
.timeout(120000)
.reporter(UseCaseReporter)
.ui('tdd');
mocha._originalRun = mocha.run;
let runner;
mocha.run = fn => {
runner = mocha._originalRun(async () => {
await failHandlerRunning.waitForEmpty();
await driver.quit();
fn();
});
};
async function useCaseExec(name, asyncFn) {
runner.emit('use-case', {title: name});
try {
await asyncFn();
runner.emit('use-case end');
} catch (err) {
runner.emit('use-case end');
throw err;
}
}
function useCase(name, asyncFn) {
if (asyncFn) {
return test('Use case: ' + name, () => useCaseExec(name, asyncFn));
} else {
// Pending test
return test('Use case: ' + name);
}
}
useCase.only = (name, asyncFn) => test.only('Use case: ' + name, () => useCaseExec(name, asyncFn));
useCase.skip = (name, asyncFn) => test.skip('Use case: ' + name, () => useCaseExec(name, asyncFn));
async function step(name, asyncFn) {
try {
await asyncFn();
runner.emit('step pass', {title: name});
} catch (err) {
runner.emit('step fail', {title: name});
throw err;
}
}
async function steps(name, asyncFn) {
try {
runner.emit('steps', {title: name});
await asyncFn();
runner.emit('steps end');
} catch (err) {
runner.emit('step end');
throw err;
}
}
async function precondition(preConditionName, useCaseName, asyncFn) {
await steps(`Including use case "${useCaseName}" to satisfy precondition "${preConditionName}"`, asyncFn);
}
module.exports = {
mocha,
useCase,
step,
steps,
precondition,
driver
};

123
test/e2e/lib/page.js Normal file
View file

@ -0,0 +1,123 @@
'use strict';
const webdriver = require('selenium-webdriver');
const By = webdriver.By;
const until = webdriver.until;
const fs = require('fs-extra');
const driver = require('./mocha-e2e').driver;
const url = require('url');
const UrlPattern = require('url-pattern');
const waitTimeout = 10000;
module.exports = (...extras) => Object.assign({
elements: {},
async getElement(key) {
return await driver.findElement(By.css(this.elements[key]));
},
async getLinkParams(key) {
const elem = await driver.findElement(By.css(this.elements[key]));
const linkUrl = await elem.getAttribute('href');
const linkPath = url.parse(linkUrl).path;
const urlPattern = new UrlPattern(this.links[key]);
const params = urlPattern.match(linkPath);
if (!params) {
throw new Error(`Cannot match URL pattern ${this.links[key]}`);
}
return params;
},
async waitUntilVisible(selector) {
await driver.wait(until.elementLocated(By.css('body')), waitTimeout);
if (selector) {
await driver.wait(until.elementLocated(By.css(selector)), waitTimeout);
}
for (const elem of (this.elementsToWaitFor || [])) {
const sel = this.elements[elem];
if (!sel) {
throw new Error(`Element "${elem}" not found.`);
}
await driver.wait(until.elementLocated(By.css(sel)), waitTimeout);
}
for (const text of (this.textsToWaitFor || [])) {
await driver.wait(new webdriver.Condition(`for text "${text}"`, async () => await this.containsText(text)), waitTimeout);
}
if (this.url) {
await this.ensureUrl();
}
await driver.executeScript('document.mailTrainRefreshAcknowledged = true;');
},
async waitUntilVisibleAfterRefresh(selector) {
await driver.wait(new webdriver.Condition('for refresh', async driver => {
const val = await driver.executeScript('return document.mailTrainRefreshAcknowledged;');
return !val;
}), waitTimeout);
await this.waitUntilVisible(selector);
},
async click(key) {
const elem = await this.getElement(key);
await elem.click();
},
async getHref(key) {
const elem = await this.getElement(key);
return await elem.getAttribute('href');
},
async getText(key) {
const elem = await this.getElement(key);
return await elem.getText();
},
async getValue(key) {
const elem = await this.getElement(key);
return await elem.getAttribute('value');
},
async containsText(str) {
return await driver.executeScript(`
return (document.documentElement.innerText || document.documentElement.textContent).indexOf('${str}') > -1;
`);
},
async getSource() {
return await driver.getPageSource();
},
async saveSource(destPath) {
const src = await this.getSource();
await fs.writeFile(destPath, src);
},
async saveScreenshot(destPath) {
const pngData = await driver.takeScreenshot();
const buf = new Buffer(pngData, 'base64');
await fs.writeFile(destPath, buf);
},
async saveSnapshot(destPathBase) {
destPathBase = destPathBase || 'last-failed-e2e-test';
const currentUrl = await driver.getCurrentUrl();
const info = `URL: ${currentUrl}`;
await fs.writeFile(destPathBase + '.info', info);
await this.saveSource(destPathBase + '.html');
await this.saveScreenshot(destPathBase + '.png');
},
async sleep(ms) {
await driver.sleep(ms);
}
}, ...extras);

77
test/e2e/lib/web.js Normal file
View file

@ -0,0 +1,77 @@
'use strict';
const config = require('./config');
const By = require('selenium-webdriver').By;
const url = require('url');
const UrlPattern = require('url-pattern');
const driver = require('./mocha-e2e').driver;
const page = require('./page');
module.exports = (...extras) => page({
async navigate(pathOrParams) {
let path;
if (typeof pathOrParams === 'string') {
path = pathOrParams;
} else {
const urlPattern = new UrlPattern(this.requestUrl || this.url);
path = urlPattern.stringify(pathOrParams);
}
const parsedUrl = url.parse(path);
let absolutePath;
if (parsedUrl.host) {
absolutePath = path;
} else {
absolutePath = config.baseUrl + path;
}
await driver.navigate().to(absolutePath);
await this.waitUntilVisible();
},
async ensureUrl(path) {
const desiredUrl = path || this.url;
if (desiredUrl) {
const currentUrl = url.parse(await driver.getCurrentUrl());
const urlPattern = new UrlPattern(desiredUrl);
const params = urlPattern.match(currentUrl.pathname);
if (!params || config.baseUrl !== `${currentUrl.protocol}//${currentUrl.host}`) {
throw new Error(`Unexpected URL. Expecting ${config.baseUrl}${this.url} got ${currentUrl.protocol}//${currentUrl.host}/${currentUrl.pathname}`);
}
this.params = params;
}
},
async submit() {
const submitButton = await this.getElement('submitButton');
await submitButton.click();
},
async waitForFlash() {
await this.waitUntilVisible('div.alert:not(.js-warning)');
},
async getFlash() {
const elem = await driver.findElement(By.css('div.alert:not(.js-warning)'));
return await elem.getText();
},
async clearFlash() {
await driver.executeScript(`
var elements = document.getElementsByClassName('alert');
while(elements.length > 0){
elements[0].parentNode.removeChild(elements[0]);
}
`);
},
async setValue(key, value) {
const elem = await this.getElement(key);
await elem.clear();
await elem.sendKeys(value);
}
}, ...extras);

View file

@ -0,0 +1,35 @@
'use strict';
const Promise = require('bluebird');
class WorkerCounter {
constructor() {
this.counter = 0;
}
enter() {
this.counter++;
}
exit() {
this.counter--;
}
async waitForEmpty() {
const self = this;
function wait(resolve) {
if (self.counter === 0) {
resolve();
} else {
setTimeout(wait, 500, resolve);
}
}
return new Promise(resolve => {
setTimeout(wait, 500, resolve);
});
}
}
module.exports = WorkerCounter;

View file

@ -1,21 +0,0 @@
'use strict';
const page = require('./page');
module.exports = driver => Object.assign(page(driver), {
elementToWaitFor: 'alert',
elements: {
alert: 'div.alert:not(.js-warning)'
},
getText() {
return this.element('alert').getText();
},
clear() {
return this.driver.executeScript(`
var elements = document.getElementsByClassName('alert');
while(elements.length > 0){
elements[0].parentNode.removeChild(elements[0]);
}
`);
}
});

View file

@ -1,11 +1,7 @@
'use strict';
const page = require('./page');
const web = require('../lib/web');
module.exports = driver => Object.assign(page(driver), {
url: '/',
elementToWaitFor: 'body',
elements: {
body: 'body.page--home'
}
module.exports = web({
url: '/'
});

View file

@ -1,55 +0,0 @@
'use strict';
const config = require('../helpers/config');
const webdriver = require('selenium-webdriver');
const By = webdriver.By;
const until = webdriver.until;
module.exports = driver => ({
driver,
elements: {},
element(key) {
return this.driver.findElement(By.css(this.elements[key] || key));
},
navigate(path) {
this.driver.navigate().to(config.baseUrl + (path || this.url));
return this.waitUntilVisible();
},
waitUntilVisible() {
let selector = this.elements[this.elementToWaitFor];
if (!selector && this.url) {
selector = 'body.page--' + (this.url.substring(1).replace(/\//g, '--') || 'home');
}
return selector ? this.driver.wait(until.elementLocated(By.css(selector))) : this.driver.sleep(1000);
},
submit() {
return this.element('submitButton').click();
},
click(key) {
return this.element(key).click();
},
getText(key) {
return this.element(key).getText();
},
getValue(key) {
return this.element(key).getAttribute('value');
},
setValue(key, value) {
return this.element(key).sendKeys(value);
},
containsText(str) {
// let text = await driver.findElement({ css: 'body' }).getText();
return this.driver.executeScript(`
return (document.documentElement.textContent || document.documentElement.innerText).indexOf('${str}') > -1;
`);
}
});

View file

@ -1,84 +1,156 @@
'use strict';
const config = require('../helpers/config');
const page = require('./page');
const config = require('../lib/config');
const web = require('../lib/web');
const mail = require('../lib/mail');
const web = {
enterEmail(value) {
this.element('emailInput').clear();
return this.element('emailInput').sendKeys(value);
}
};
module.exports = list => ({
const mail = {
navigate(address) {
this.driver.sleep(100);
this.driver.navigate().to(`http://localhost:${config.app.testserver.mailboxserverport}/${address}`);
return this.waitUntilVisible();
}
};
module.exports = (driver, list) => ({
webSubscribe: Object.assign(page(driver), web, {
webSubscribe: web({
url: `/subscription/${list.cid}`,
elementToWaitFor: 'form',
elementsToWaitFor: ['form'],
textsToWaitFor: ['Subscribe to list'],
elements: {
form: `form[action="/subscription/${list.cid}/subscribe"]`,
emailInput: '#main-form input[name="email"]',
firstNameInput: '#main-form input[name="first-name"]',
lastNameInput: '#main-form input[name="last-name"]',
submitButton: 'a[href="#submit"]'
}
}),
webConfirmSubscriptionNotice: Object.assign(page(driver), web, {
url: `/subscription/${list.cid}/confirm-notice`,
elementToWaitFor: 'homepageButton',
webSubscribeNonPublic: web({
url: `/subscription/${list.cid}`,
textsToWaitFor: ['The list does not allow public subscriptions'],
}),
webConfirmSubscriptionNotice: web({
url: `/subscription/${list.cid}/confirm-subscription-notice`,
textsToWaitFor: ['We need to confirm your email address']
}),
mailConfirmSubscription: mail({
elementsToWaitFor: ['confirmLink'],
textsToWaitFor: ['Please Confirm Subscription'],
elements: {
homepageButton: `a[href="${config.settings['default-homepage']}"]`
confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/subscribe/"]`
}
}),
mailConfirmSubscription: Object.assign(page(driver), mail, {
elementToWaitFor: 'confirmLink',
mailAlreadySubscribed: mail({
elementsToWaitFor: ['unsubscribeLink'],
textsToWaitFor: ['Email address already registered'],
elements: {
confirmLink: `a[href^="${config.settings['service-url']}subscription/subscribe/"]`
unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`,
manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]`
},
links: {
unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`,
manageLink: `/subscription/${list.cid}/manage/:ucid`
}
}),
webSubscribedNotice: Object.assign(page(driver), web, {
elementToWaitFor: 'homepageButton',
webSubscribedNotice: web({
url: `/subscription/${list.cid}/subscribed-notice`,
textsToWaitFor: ['Subscription Confirmed']
}),
mailSubscriptionConfirmed: mail({
elementsToWaitFor: ['unsubscribeLink'],
textsToWaitFor: ['Subscription Confirmed'],
elements: {
homepageButton: 'a[href^="https://mailtrain.org"]'
unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`,
manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]`
},
links: {
unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`,
manageLink: `/subscription/${list.cid}/manage/:ucid`
}
}),
mailSubscriptionConfirmed: Object.assign(page(driver), mail, {
elementToWaitFor: 'unsubscribeLink',
webManage: web({
url: `/subscription/${list.cid}/manage/:ucid`,
elementsToWaitFor: ['form'],
textsToWaitFor: ['Update Your Preferences'],
elements: {
unsubscribeLink: 'a[href*="/unsubscribe/"]',
manageLink: 'a[href*="/manage/"]'
form: `form[action="/subscription/${list.cid}/manage"]`,
emailInput: '#main-form input[name="email"]',
firstNameInput: '#main-form input[name="first-name"]',
lastNameInput: '#main-form input[name="last-name"]',
submitButton: 'a[href="#submit"]',
manageAddressLink: `a[href^="/subscription/${list.cid}/manage-address/"]`
},
links: {
manageAddressLink: `/subscription/${list.cid}/manage-address/:ucid`
}
}),
webUnsubscribe: Object.assign(page(driver), web, {
elementToWaitFor: 'submitButton',
webManageAddress: web({
url: `/subscription/${list.cid}/manage-address/:ucid`,
elementsToWaitFor: ['form'],
textsToWaitFor: ['Update Your Email Address'],
elements: {
form: `form[action="/subscription/${list.cid}/manage-address"]`,
emailInput: '#main-form input[name="email"]',
emailNewInput: '#main-form input[name="email-new"]',
submitButton: 'a[href="#submit"]'
}
}),
webUnsubscribedNotice: Object.assign(page(driver), web, {
elementToWaitFor: 'homepageButton',
mailConfirmAddressChange: mail({
elementsToWaitFor: ['confirmLink'],
textsToWaitFor: ['Please Confirm Subscription Address Change'],
elements: {
homepageButton: 'a[href^="https://mailtrain.org"]'
confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/change-address/"]`
}
}),
mailUnsubscriptionConfirmed: Object.assign(page(driver), mail, {
elementToWaitFor: 'resubscribeLink',
webUpdatedNotice: web({
url: `/subscription/${list.cid}/updated-notice`,
textsToWaitFor: ['Profile Updated']
}),
webUnsubscribedNotice: web({
url: `/subscription/${list.cid}/unsubscribed-notice`,
textsToWaitFor: ['Unsubscribe Successful']
}),
mailUnsubscriptionConfirmed: mail({
elementsToWaitFor: ['resubscribeLink'],
textsToWaitFor: ['You Are Now Unsubscribed'],
elements: {
resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]`
}
})
}),
webUnsubscribe: web({
elementsToWaitFor: ['submitButton'],
textsToWaitFor: ['Unsubscribe'],
elements: {
submitButton: 'a[href="#submit"]'
}
}),
webConfirmUnsubscriptionNotice: web({
url: `/subscription/${list.cid}/confirm-unsubscription-notice`,
textsToWaitFor: ['We need to confirm your email address']
}),
mailConfirmUnsubscription: mail({
elementsToWaitFor: ['confirmLink'],
textsToWaitFor: ['Please Confirm Unsubscription'],
elements: {
confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/unsubscribe/"]`
}
}),
webManualUnsubscribeNotice: web({
url: `/subscription/${list.cid}/manual-unsubscribe-notice`,
elementsToWaitFor: ['contactLink'],
textsToWaitFor: ['Online Unsubscription Is Not Possible', config.settings['admin-email']],
elements: {
contactLink: `a[href^="mailto:${config.settings['admin-email']}"]`
}
}),
});

View file

@ -1,32 +1,29 @@
'use strict';
const page = require('./page');
const web = require('../lib/web');
module.exports = driver => ({
login: Object.assign(page(driver), {
module.exports = {
login: web({
url: '/users/login',
elementToWaitFor: 'submitButton',
elementsToWaitFor: ['submitButton'],
elements: {
usernameInput: 'form[action="/users/login"] input[name="username"]',
passwordInput: 'form[action="/users/login"] input[name="password"]',
submitButton: 'form[action="/users/login"] [type=submit]'
},
enterUsername(value) {
// this.element('usernameInput').clear();
return this.element('usernameInput').sendKeys(value);
},
enterPassword(value) {
return this.element('passwordInput').sendKeys(value);
}
}),
account: Object.assign(page(driver), {
logout: web({
requestUrl: '/users/logout',
url: '/'
}),
account: web({
url: '/users/account',
elementToWaitFor: 'emailInput',
elementsToWaitFor: ['form'],
elements: {
form: 'form[action="/users/account"]',
emailInput: 'form[action="/users/account"] input[name="email"]'
}
})
});
};

View file

@ -1,57 +1,70 @@
'use strict';
const config = require('../helpers/config');
/* eslint-disable prefer-arrow-callback */
const config = require('../lib/config');
const { useCase, step, driver } = require('../lib/mocha-e2e');
const expect = require('chai').expect;
const driver = require('../helpers/driver');
const home = require('../page-objects/home')(driver);
const flash = require('../page-objects/flash')(driver);
const {
login,
account
} = require('../page-objects/users')(driver);
describe('login', function() {
this.timeout(10000);
const page = require('../page-objects/user');
const home = require('../page-objects/home');
suite('Login use-cases', () => {
before(() => driver.manage().deleteAllCookies());
it('can access home page', async () => {
test('User can access home page', async () => {
await home.navigate();
});
it('can not access restricted content', async () => {
driver.navigate().to(config.baseUrl + '/settings');
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('Need to be logged in to access restricted content');
await flash.clear();
test('Anonymous user cannot access restricted content', async () => {
await driver.navigate().to(config.baseUrl + '/settings');
await page.login.waitUntilVisible();
await page.login.waitForFlash();
expect(await page.login.getFlash()).to.contain('Need to be logged in to access restricted content');
});
it('can not login with false credentials', async () => {
login.enterUsername(config.users.admin.username);
login.enterPassword('invalid');
login.submit();
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('Incorrect username or password');
await flash.clear();
useCase('Login (invalid credential)', async () => {
await step('User navigates to the login page.', async () => {
await page.login.navigate();
});
it('can login as admin', async () => {
login.enterUsername(config.users.admin.username);
login.enterPassword(config.users.admin.password);
login.submit();
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('Logged in as admin');
await step('User fills in the user name and incorrect password.', async () => {
await page.login.setValue('usernameInput', config.users.admin.username);
await page.login.setValue('passwordInput', 'invalid');
await page.login.submit();
});
it('can access account page as admin', async () => {
await account.navigate();
await step('System shows a flash notice that credentials are invalid.', async () => {
await page.login.waitForFlash();
expect(await page.login.getFlash()).to.contain('Incorrect username or password');
});
});
it('can logout', async () => {
driver.navigate().to(config.baseUrl + '/users/logout');
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('logged out');
useCase('Login and logout', async () => {
await step('User navigates to the login page.', async () => {
await page.login.navigate();
});
after(() => driver.quit());
await step('User fills in the user name and password.', async () => {
await page.login.setValue('usernameInput', config.users.admin.username);
await page.login.setValue('passwordInput', config.users.admin.password);
await page.login.submit();
});
await step('System shows the home page and a flash notice that user has been logged in.', async () => {
await home.waitUntilVisibleAfterRefresh();
await home.waitForFlash();
expect(await home.getFlash()).to.contain('Logged in as admin');
});
await step('User navigates to its account.', async () => {
await page.account.navigate();
});
await step('User logs out.', async () => {
await page.logout.navigate();
await home.waitForFlash();
expect(await home.getFlash()).to.contain('logged out');
});
});
});

View file

@ -1,101 +1,439 @@
'use strict';
const config = require('../helpers/config');
/* eslint-disable prefer-arrow-callback */
const config = require('../lib/config');
const { useCase, step, precondition, driver } = require('../lib/mocha-e2e');
const shortid = require('shortid');
const expect = require('chai').expect;
const driver = require('../helpers/driver');
const createPage = require('../page-objects/subscription');
const page = require('../page-objects/page')(driver);
const flash = require('../page-objects/flash')(driver);
function getPage(listConf) {
return createPage(listConf);
}
const {
webSubscribe,
webConfirmSubscriptionNotice,
mailConfirmSubscription,
webSubscribedNotice,
mailSubscriptionConfirmed,
webUnsubscribe,
webUnsubscribedNotice,
mailUnsubscriptionConfirmed
} = require('../page-objects/subscription')(driver, config.lists.one);
function generateEmail() {
return 'keep.' + shortid.generate() + '@mailtrain.org';
}
const testuser = {
email: 'keep.' + shortid.generate() + '@mailtrain.org'
};
async function subscribe(listConf, subscription) {
const page = getPage(listConf);
// console.log(testuser.email);
await step('User navigates to list subscription page.', async () => {
await page.webSubscribe.navigate();
});
describe('subscribe (list one)', function() {
this.timeout(10000);
await step('User submits a valid email and other subscription info.', async () => {
await page.webSubscribe.setValue('emailInput', subscription.email);
if (subscription.firstName) {
await page.webSubscribe.setValue('firstNameInput', subscription.firstName);
}
if (subscription.lastName) {
await page.webSubscribe.setValue('lastNameInput', subscription.lastName);
}
await page.webSubscribe.submit();
});
await step('System shows a notice that further instructions are in the email.', async () => {
await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email with a link to confirm the subscription.', async () => {
await page.mailConfirmSubscription.fetchMail(subscription.email);
});
await step('User clicks confirm subscription in the email', async () => {
await page.mailConfirmSubscription.click('confirmLink');
});
await step('System shows a notice that subscription has been confirmed.', async () => {
await page.webSubscribedNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email with subscription confirmation.', async () => {
await page.mailSubscriptionConfirmed.fetchMail(subscription.email);
subscription.unsubscribeLink = await page.mailSubscriptionConfirmed.getHref('unsubscribeLink');
subscription.manageLink = await page.mailSubscriptionConfirmed.getHref('manageLink');
const unsubscribeParams = await page.mailSubscriptionConfirmed.getLinkParams('unsubscribeLink');
const manageParams = await page.mailSubscriptionConfirmed.getLinkParams('manageLink');
expect(unsubscribeParams.ucid).to.equal(manageParams.ucid);
subscription.ucid = unsubscribeParams.ucid;
});
return subscription;
}
async function subscriptionExistsPrecondition(listConf, subscription) {
await precondition('Subscription exists', 'Subscription to a public list (main scenario)', async () => {
await subscribe(listConf, subscription);
});
return subscription;
}
suite('Subscription use-cases', () => {
before(() => driver.manage().deleteAllCookies());
it('visits web-subscribe', async () => {
await webSubscribe.navigate();
});
it('submits invalid email (error)', async () => {
webSubscribe.enterEmail('foo@bar.nope');
webSubscribe.submit();
flash.waitUntilVisible();
expect(await flash.getText()).to.contain('Invalid email address');
});
it('submits valid email', async () => {
webSubscribe.enterEmail(testuser.email);
await webSubscribe.submit();
});
it('sees web-confirm-subscription-notice', async () => {
webConfirmSubscriptionNotice.waitUntilVisible();
expect(await page.containsText('Almost Finished')).to.be.true;
});
it('receives mail-confirm-subscription', async () => {
mailConfirmSubscription.navigate(testuser.email);
expect(await page.containsText('Please Confirm Subscription')).to.be.true;
});
it('clicks confirm subscription', async () => {
await mailConfirmSubscription.click('confirmLink');
});
it('sees web-subscribed-notice', async () => {
webSubscribedNotice.waitUntilVisible();
expect(await page.containsText('Subscription Confirmed')).to.be.true;
});
it('receives mail-subscription-confirmed', async () => {
mailSubscriptionConfirmed.navigate(testuser.email);
expect(await page.containsText('Subscription Confirmed')).to.be.true;
useCase('Subscription to a public list (main scenario)', async () => {
await subscribe(config.lists.l1, {
email: generateEmail()
});
});
describe('unsubscribe (list one)', function() {
this.timeout(10000);
useCase('Subscription to a public list (invalid email)', async () => {
const page = getPage(config.lists.l1);
it('clicks unsubscribe', async () => {
await mailSubscriptionConfirmed.click('unsubscribeLink');
await step('User navigates to list subscribe page', async () => {
await page.webSubscribe.navigate();
});
it('sees web-unsubscribe', async () => {
webUnsubscribe.waitUntilVisible();
expect(await page.containsText('Unsubscribe')).to.be.true;
await step('User submits an invalid email.', async () => {
await page.webSubscribe.setValue('emailInput', 'foo@bar.nope');
await page.webSubscribe.submit();
});
it('clicks confirm unsubscription', async () => {
await webUnsubscribe.submit();
await step('System shows a flash notice that email is invalid.', async () => {
await page.webSubscribe.waitForFlash();
expect(await page.webSubscribe.getFlash()).to.contain('Invalid email address');
});
});
it('sees web-unsubscribed-notice', async () => {
webUnsubscribedNotice.waitUntilVisible();
expect(await page.containsText('Unsubscribe Successful')).to.be.true;
useCase('Subscription to a public list (email already registered)', async () => {
const page = getPage(config.lists.l1);
const subscription = await subscriptionExistsPrecondition(config.lists.l1, {
email: generateEmail()
});
it('receives mail-unsubscription-confirmed', async () => {
mailUnsubscriptionConfirmed.navigate(testuser.email);
expect(await page.containsText('You Are Now Unsubscribed')).to.be.true;
await step('User navigates to list subscribe page', async () => {
await page.webSubscribe.navigate();
});
await step('User submits the email which has been already registered.', async () => {
await page.webSubscribe.setValue('emailInput', subscription.email);
await page.webSubscribe.submit();
});
await step('System shows a notice that further instructions are in the email.', async () => {
await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email informing that the address has been already registered.', async () => {
await page.mailAlreadySubscribed.fetchMail(subscription.email);
});
});
useCase('Subscription to a non-public list', async () => {
const page = getPage(config.lists.l6);
await step('User navigates to list subscription page and sees message that this list does not allow public subscriptions.', async () => {
await page.webSubscribeNonPublic.navigate();
});
});
useCase('Change profile info', async () => {
const page = getPage(config.lists.l1);
const subscription = await subscriptionExistsPrecondition(config.lists.l1, {
email: generateEmail(),
firstName: 'John',
lastName: 'Doe'
});
await step('User clicks the manage subscription button.', async () => {
await page.mailSubscriptionConfirmed.click('manageLink');
});
await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => {
await page.webManage.waitUntilVisibleAfterRefresh();
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName);
expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName);
});
await step('User enters another name and submits the form.', async () => {
subscription.firstName = 'Adam';
subscription.lastName = 'B';
await page.webManage.setValue('firstNameInput', subscription.firstName);
await page.webManage.setValue('lastNameInput', subscription.lastName);
await page.webManage.submit();
});
await step('Systems shows a notice that profile has been updated.', async () => {
await page.webUpdatedNotice.waitUntilVisibleAfterRefresh();
});
await step('User navigates to manage subscription again.', async () => {
// await page.webManage.navigate(subscription.manageLink);
await page.webManage.navigate({ ucid: subscription.ucid });
});
await step('Systems shows a form with the changes made previously.', async () => {
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName);
expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName);
});
});
useCase('Change email', async () => {
const page = getPage(config.lists.l1);
const subscription = await subscriptionExistsPrecondition(config.lists.l1, {
email: generateEmail(),
firstName: 'John',
lastName: 'Doe'
});
await step('User clicks the manage subscription button.', async () => {
await page.mailSubscriptionConfirmed.click('manageLink');
});
await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => {
await page.webManage.waitUntilVisibleAfterRefresh();
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName);
expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName);
});
await step('User clicks the change address button.', async () => {
await page.webManage.click('manageAddressLink');
});
await step('Systems shows a form to change email.', async () => {
await page.webManageAddress.waitUntilVisibleAfterRefresh();
});
await step('User fills in a new email address and submits the form.', async () => {
subscription.email = generateEmail();
await page.webManageAddress.setValue('emailNewInput', subscription.email);
await page.webManageAddress.submit();
});
await step('System goes back to the profile form and shows a flash notice that further instructions are in the email.', async () => {
await page.webManage.waitUntilVisibleAfterRefresh();
await page.webManage.waitForFlash();
expect(await page.webManage.getFlash()).to.contain('An email with further instructions has been sent to the provided address');
});
await step('System sends an email with a link to confirm the address change.', async () => {
await page.mailConfirmAddressChange.fetchMail(subscription.email);
});
await step('User clicks confirm subscription in the email', async () => {
await page.mailConfirmAddressChange.click('confirmLink');
});
await step('System shows the profile form with a flash notice that address has been changed.', async () => {
await page.webManage.waitUntilVisibleAfterRefresh();
await page.webManage.waitForFlash();
expect(await page.webManage.getFlash()).to.contain('Email address changed');
expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email);
});
await step('System sends an email with subscription confirmation.', async () => {
await page.mailSubscriptionConfirmed.fetchMail(subscription.email);
});
});
useCase('Unsubscription from list #1 (one-step, no form).', async () => {
const page = getPage(config.lists.l1);
const subscription = await subscriptionExistsPrecondition(config.lists.l1, {
email: generateEmail()
});
await step('User clicks the unsubscribe button.', async () => {
await page.mailSubscriptionConfirmed.click('unsubscribeLink');
});
await step('System shows a notice that confirms unsubscription.', async () => {
await page.webUnsubscribedNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email that confirms unsubscription.', async () => {
await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email);
});
});
useCase('Unsubscription from list #2 (one-step, with form).', async () => {
const page = getPage(config.lists.l2);
const subscription = await subscriptionExistsPrecondition(config.lists.l2, {
email: generateEmail()
});
await step('User clicks the unsubscribe button.', async () => {
await page.mailSubscriptionConfirmed.click('unsubscribeLink');
});
await step('Systems shows a form to unsubscribe.', async () => {
await page.webUnsubscribe.waitUntilVisibleAfterRefresh();
});
await step('User confirms unsubscribe and clicks the unsubscribe button.', async () => {
await page.webUnsubscribe.submit();
});
await step('System shows a notice that confirms unsubscription.', async () => {
await page.webUnsubscribedNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email that confirms unsubscription.', async () => {
await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email);
});
});
useCase('Unsubscription from list #3 (two-step, no form).', async () => {
const page = getPage(config.lists.l3);
const subscription = await subscriptionExistsPrecondition(config.lists.l3, {
email: generateEmail()
});
await step('User clicks the unsubscribe button.', async () => {
await page.mailSubscriptionConfirmed.click('unsubscribeLink');
});
await step('System shows a notice that further instructions are in the email.', async () => {
await page.webConfirmUnsubscriptionNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email with a link to confirm unsubscription.', async () => {
await page.mailConfirmUnsubscription.fetchMail(subscription.email);
});
await step('User clicks the confirm unsubscribe button in the email.', async () => {
await page.mailConfirmUnsubscription.click('confirmLink');
});
await step('System shows a notice that confirms unsubscription.', async () => {
await page.webUnsubscribedNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email that confirms unsubscription.', async () => {
await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email);
});
});
useCase('Unsubscription from list #4 (two-step, with form).', async () => {
const page = getPage(config.lists.l4);
const subscription = await subscriptionExistsPrecondition(config.lists.l4, {
email: generateEmail()
});
await step('User clicks the unsubscribe button.', async () => {
await page.mailSubscriptionConfirmed.click('unsubscribeLink');
});
await step('Systems shows a form to unsubscribe.', async () => {
await page.webUnsubscribe.waitUntilVisibleAfterRefresh();
});
await step('User confirms unsubscribe and clicks the unsubscribe button.', async () => {
await page.webUnsubscribe.submit();
});
await step('System shows a notice that further instructions are in the email.', async () => {
await page.webConfirmUnsubscriptionNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email with a link to confirm unsubscription.', async () => {
await page.mailConfirmUnsubscription.fetchMail(subscription.email);
});
await step('User clicks the confirm unsubscribe button in the email.', async () => {
await page.mailConfirmUnsubscription.click('confirmLink');
});
await step('System shows a notice that confirms unsubscription.', async () => {
await page.webUnsubscribedNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email that confirms unsubscription.', async () => {
await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email);
});
});
useCase('Unsubscription from list #5 (manual unsubscribe).', async () => {
const page = getPage(config.lists.l5);
await subscriptionExistsPrecondition(config.lists.l5, {
email: generateEmail()
});
await step('User clicks the unsubscribe button.', async () => {
await page.mailSubscriptionConfirmed.click('unsubscribeLink');
});
await step('Systems shows a notice that online unsubscription is not possible.', async () => {
await page.webManualUnsubscribeNotice.waitUntilVisibleAfterRefresh();
});
});
useCase('Resubscription.', async () => {
const page = getPage(config.lists.l1);
const subscription = await subscriptionExistsPrecondition(config.lists.l1, {
email: generateEmail(),
firstName: 'John',
lastName: 'Doe'
});
await step('User clicks the unsubscribe button.', async () => {
await page.mailSubscriptionConfirmed.click('unsubscribeLink');
});
await step('System shows a notice that confirms unsubscription.', async () => {
await page.webUnsubscribedNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email that confirms unsubscription.', async () => {
await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email);
});
await step('User clicks the resubscribe button.', async () => {
await page.mailUnsubscriptionConfirmed.click('resubscribeLink');
});
await step('Systems shows the subscription form. The form contains data entered during initial subscription.', async () => {
await page.webSubscribe.waitUntilVisibleAfterRefresh();
expect(await page.webSubscribe.getValue('emailInput')).to.equal(subscription.email);
expect(await page.webSubscribe.getValue('firstNameInput')).to.equal(subscription.firstName);
expect(await page.webSubscribe.getValue('lastNameInput')).to.equal(subscription.lastName);
});
await step('User submits the subscription form.', async () => {
await page.webSubscribe.submit();
});
await step('System shows a notice that further instructions are in the email.', async () => {
await page.webConfirmSubscriptionNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email with a link to confirm the subscription.', async () => {
await page.mailConfirmSubscription.fetchMail(subscription.email);
});
await step('User clicks confirm subscription in the email', async () => {
await page.mailConfirmSubscription.click('confirmLink');
});
await step('System shows a notice that subscription has been confirmed.', async () => {
await page.webSubscribedNotice.waitUntilVisibleAfterRefresh();
});
await step('System sends an email with subscription confirmation. The manage and unsubscribe links are identical with the initial subscription.', async () => {
await page.mailSubscriptionConfirmed.fetchMail(subscription.email);
const unsubscribeLink = await page.mailSubscriptionConfirmed.getHref('unsubscribeLink');
const manageLink = await page.mailSubscriptionConfirmed.getHref('manageLink');
expect(subscription.unsubscribeLink).to.equal(unsubscribeLink);
expect(subscription.manageLink).to.equal(manageLink);
});
});
after(() => driver.quit());
});

View file

@ -84,7 +84,15 @@
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="tracking-disabled" value="1" {{#if trackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked/opened tracking{{/translate}}
<input type="checkbox" name="open-tracking-disabled" value="1" {{#if openTrackingDisabled}} checked {{/if}}> {{#translate}}Disable opened tracking{{/translate}}
</label>
</div>
</div>
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="click-tracking-disabled" value="1" {{#if clickTrackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked tracking{{/translate}}
</label>
</div>
</div>

View file

@ -104,7 +104,15 @@
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="tracking-disabled" value="1" {{#if trackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked/opened tracking{{/translate}}
<input type="checkbox" name="open-tracking-disabled" value="1" {{#if openTrackingDisabled}} checked {{/if}}> {{#translate}}Disable opened tracking{{/translate}}
</label>
</div>
</div>
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="click-tracking-disabled" value="1" {{#if clickTrackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked tracking{{/translate}}
</label>
</div>
</div>

View file

@ -110,7 +110,15 @@
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="tracking-disabled" value="1" {{#if trackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked/opened tracking{{/translate}}
<input type="checkbox" name="open-tracking-disabled" value="1" {{#if openTrackingDisabled}} checked {{/if}}> {{#translate}}Disable opened tracking{{/translate}}
</label>
</div>
</div>
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="click-tracking-disabled" value="1" {{#if clickTrackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked tracking{{/translate}}
</label>
</div>
</div>

View file

@ -111,7 +111,15 @@
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="tracking-disabled" value="1" {{#if trackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked/opened tracking{{/translate}}
<input type="checkbox" name="open-tracking-disabled" value="1" {{#if openTrackingDisabled}} checked {{/if}}> {{#translate}}Disable opened tracking{{/translate}}
</label>
</div>
</div>
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="click-tracking-disabled" value="1" {{#if clickTrackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked tracking{{/translate}}
</label>
</div>
</div>

View file

@ -103,7 +103,15 @@
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="tracking-disabled" value="1" {{#if trackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked/opened tracking{{/translate}}
<input type="checkbox" name="open-tracking-disabled" value="1" {{#if openTrackingDisabled}} checked {{/if}}> {{#translate}}Disable opened tracking{{/translate}}
</label>
</div>
</div>
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="click-tracking-disabled" value="1" {{#if clickTrackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked tracking{{/translate}}
</label>
</div>
</div>

View file

@ -121,7 +121,15 @@
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="tracking-disabled" value="1" {{#if trackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked/opened tracking{{/translate}}
<input type="checkbox" name="open-tracking-disabled" value="1" {{#if openTrackingDisabled}} checked {{/if}}> {{#translate}}Disable opened tracking{{/translate}}
</label>
</div>
</div>
<div class="col-sm-offset-2">
<div class="checkbox">
<label>
<input type="checkbox" name="click-tracking-disabled" value="1" {{#if clickTrackingDisabled}} checked {{/if}}> {{#translate}}Disable clicked tracking{{/translate}}
</label>
</div>
</div>

View file

@ -164,7 +164,7 @@
</div>
</dd>
{{#unless trackingDisabled}}
{{#unless openTrackingDisabled}}
<dt>{{#translate}}Opened{{/translate}} <a href="/campaigns/opened/{{id}}" title="{{#translate}}List subscribers who opened this message{{/translate}}"><span class="glyphicon glyphicon-zoom-in" aria-hidden="true"></span></a></dt>
<dd>
@ -174,7 +174,8 @@
</div>
</div>
</dd>
{{/unless}}
{{#unless clickTrackingDisabled}}
<dt>{{#translate}}Clicked{{/translate}} <a href="/campaigns/clicked/{{id}}/all" title="{{#translate}}List subscribers who clicked on a link{{/translate}}"> <span class="glyphicon glyphicon-zoom-in" aria-hidden="true"></span></a></dt>
<dd>
<div class="progress">

View file

@ -59,14 +59,7 @@
<div id="gjs-wrapper">
<div id="gjs" style="height:0px; overflow:hidden">
{{#if resource.editorData.html}}
<style>{{{resource.editorData.css}}}</style>
{{{resource.editorData.html}}}
{{else}}
{{{resource.html}}}
{{/if}}
</div>
<div id="gjs" style="height: 0px; overflow: hidden"></div>
</div>
@ -136,21 +129,69 @@
<script>
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{csrfToken}}' } });
var editor = grapesjs.init({
var resource = {{{stringifiedResource}}};
var config = (function(mode) {
var c = {
clearOnRender: true,
height: '100%',
storageManager: {
type: 'none'
},
assetManager: {
assets: [],
upload: '/editorapi/upload?type={{type}}&id={{resource.id}}&editor={{resource.editorName}}',
upload: '/editorapi/upload?type={{type}}&id={{resource.id}}&editor={{editor.name}}',
uploadText: 'Drop images here or click to upload',
},
container : '#gjs',
fromElement: true,
plugins: ['gjs-preset-newsletter'],
pluginsOpts: {
'gjs-preset-newsletter': {
fromElement: false,
plugins: [],
pluginsOpts: {},
};
if (mode === 'mjml') {
var serializer = new XMLSerializer();
var doc = new DOMParser().parseFromString(resource.editorData.mjml, 'text/xml');
// convert relative to absolute urls
['mj-wrapper', 'mj-section', 'mj-navbar', 'mj-hero', 'mj-image'].forEach(function(tagName) {
var serviceUrl = window.location.protocol + '//' + window.location.host + '/';
var elements = doc.getElementsByTagName(tagName);
for (var i = 0; i < elements.length; i++) {
var node = elements[i];
var attrName = tagName === 'mj-image' ? 'src' : 'background-url';
var url = node.getAttribute(attrName);
if (url && url.substring(0, 2) === './') {
var absoluteUrl = serviceUrl + 'grapejs/templates/' + resource.editorData.template + '/' + url.substring(2);
node.setAttribute(attrName, absoluteUrl);
}
}
});
var title = doc.getElementsByTagName('mj-title')[0];
if (title) {
title.textContent = resource.name;
}
var head = doc.getElementsByTagName('mj-head')[0];
var mjHead = head ? serializer.serializeToString(head) : '<mj-head></mj-head>';
var container = doc.getElementsByTagName('mj-container')[0];
var mjContainer = container ? serializer.serializeToString(container) : '<mj-container></mj-container>';
c.plugins.push('gjs-mjml', 'gjs-preset-mjml');
c.pluginsOpts['gjs-mjml'] = {
preMjml: '<mjml>' + mjHead + '<mj-body>',
postMjml: '</mj-body></mjml>',
};
c.components = mjContainer;
}
if (mode === 'html') {
c.plugins.push('gjs-preset-newsletter');
c.pluginsOpts['gjs-preset-newsletter'] = {
modalLabelImport: 'Paste all your code here below and click import',
modalLabelExport: 'Copy the code and use it wherever you want',
codeViewerTheme: 'material',
@ -163,59 +204,151 @@
margin: 0,
padding: 0,
}
};
c.components = resource.editorData.html || resource.html || '';
c.style = resource.editorData.css || '';
}
}
});
$.getJSON('/editorapi/upload?type={{type}}&id={{resource.id}}&editor={{resource.editorName}}', function(data) {
return c;
})('{{editor.mode}}');
var editor = grapesjs.init(config);
$.getJSON('/editorapi/upload?type={{type}}&id={{resource.id}}&editor={{editor.name}}', function(data) {
editor.AssetManager.add(data.files);
});
function getPreparedHtml() {
var imgs = [];
$('.gjs-pn-buttons > .gjs-pn-btn.fa.fa-desktop').click();
$('.gjs-editor > .gjs-cv-canvas > iframe.gjs-frame')
.contents()
.find('img')
.each(function() {
var src = $(this).attr('src');
var s = src.match(/\/editorapi\/img\?src=([^&]*)/);
var encodedSrc = (s && s[1]) || encodeURIComponent(src);
var dynamicSrc = '/editorapi/img?src=' + encodedSrc + '&method=resize&params=' + $(this).width() + '%2C' + $(this).height();
imgs.push({
cls: $(this).attr('class').split(' ')[0],
dynamicSrc: dynamicSrc,
src: src,
});
});
var html = editor.runCommand('gjs-get-inlined-html');
imgs.forEach(function(img) {
html = html.replace(
'<img class="' + img.cls + '" src="' + img.src,
'<img class="' + img.cls + '" src="' + img.dynamicSrc
);
});
html = '<!doctype html><html><head><meta charset="utf-8"><title>{{resource.name}}</title></head><body>' + html + '</body></html>';
return html;
function getMjml() {
var c = config.pluginsOpts['gjs-mjml'];
return c.preMjml + editor.getHtml() + c.postMjml;
}
function getPreparedHtml(callback) {
var html;
switch ('{{editor.mode}}') {
case 'html':
html = editor.runCommand('gjs-get-inlined-html');
html = '<!doctype html><html><head><meta charset="utf-8"><title>{{resource.name}}</title></head><body>' + html + '</body></html>';
break;
case 'mjml':
var mjml = editor.runCommand('mjml-get-code');
mjml.errors.length && mjml.errors.forEach(function(err) {
console.warn(err.formattedMessage);
});
html = mjml.html;
break;
}
var frame = document.createElement('iframe');
frame.width = 2048;
frame.height = 0;
document.body.appendChild(frame);
var frameDoc = frame.contentDocument || frame.contentWindow.document;
frame.onload = function() {
var imgs = frameDoc.querySelectorAll('img');
for (var i = 0; i < imgs.length; i++) {
var img = imgs[i];
var m = img.src.match(/\/editorapi\/img\?src=([^&]*)/);
var encodedSrc = m && m[1] || encodeURIComponent(img.src);
img.src = '/editorapi/img?src=' + encodedSrc + '&method=resize&params=' + img.clientWidth + '%2C' + img.clientHeight;
}
html = '<!doctype html>' + frameDoc.documentElement.outerHTML;
document.body.removeChild(frame);
callback(html);
};
frameDoc.open();
frameDoc.write(html);
frameDoc.close();
}
// Save Button
window.bridge = window.bridge || {};
$('#mt-save').on('click', function() {
if ($(this).hasClass('busy')) {
return;
}
$(this).addClass('busy');
getPreparedHtml(function(html) {
var editorData = '{{editor.mode}}' === 'mjml' ? {
template: resource.editorData.template,
mjml: getMjml(),
} : {
template: resource.editorData.template,
css: editor.getCss(),
html: editor.getHtml(),
style: editor.getStyle(),
components: editor.getComponents(),
};
// TODO: Make templates and campaigns accept partial updates, i.e. don't require 'name' and 'list'
var update = {
id: resource.id,
name: resource.name,
{{#if resource.list}} list: resource.list, {{/if}}
editorData: JSON.stringify(editorData),
html: html,
};
$.post('/editorapi/update?type={{type}}&editor={{editor.name}}', update, null, 'html')
.success(function() {
window.bridge.lastSavedHtml = html;
toastr.success('Sucessfully saved');
})
.fail(function(data) {
toastr.error(data.responseText || 'An error occured while saving the document');
})
.always(function() {
setTimeout(function() {
$('#mt-save').removeClass('busy');
}, 200); // Don't save too fast
});
});
});
// Close Button
$('#mt-close').on('click', function() {
if (confirm('Unsaved changes will be lost. Close now?') === true) {
window.bridge.exit
? window.bridge.exit()
: window.location.href = '/{{type}}s/edit/{{resource.id}}?tab=template';
}
});
// Commands
var mdlClass = 'gjs-mdl-dialog-sm';
var pnm = editor.Panels;
var cmdm = editor.Commands;
var testContainer = document.getElementById("test-form");
var contentEl = testContainer.querySelector('input[name=html]');
var md = editor.Modal;
// Test email command
var testContainer = document.getElementById('test-form');
var testContentEl = testContainer.querySelector('input[name=html]');
cmdm.add('send-test', {
run(editor, sender) {
// TODO: Show a spinner
getPreparedHtml(function(html) {
sender.set('active', 0);
var modalContent = md.getContentEl();
var mdlDialog = document.querySelector('.gjs-mdl-dialog');
// var cmdGetCode = cmdm.get('gjs-get-inlined-html');
// contentEl.value = cmdGetCode && cmdGetCode.run(editor);
contentEl.value = getPreparedHtml();
testContentEl.value = html;
mdlDialog.className += ' ' + mdlClass;
testContainer.style.display = 'block';
md.setTitle('Test your Newsletter');
@ -224,7 +357,8 @@
md.getModel().once('change:open', function() {
mdlDialog.className = mdlDialog.className.replace(mdlClass, '');
//clean status
})
});
});
}
});
@ -240,6 +374,7 @@
var statusFormElC = document.querySelector('.form-status');
var statusFormEl = document.querySelector('.form-status i');
var ajaxTest = ajaxable(testContainer, { headers: { 'X-CSRF-TOKEN': '{{csrfToken}}' } })
.onStart(function() {
statusFormEl.className = 'fa fa-refresh anim-spin';
@ -260,7 +395,24 @@
}
});
// Add Merge Tag Reference command
// Remember testemail address
var isValidEmail = function(email) {
return /\S+@\S+\.\S+/.test(email);
};
if (isValidEmail(localStorage.getItem('testemail'))) {
$('#test-form input[name=email]').val(localStorage.getItem('testemail'));
}
$('#test-form').on('submit', function() {
var email = $('#test-form input[name=email]').val();
isValidEmail(email) && localStorage.setItem('testemail', email);
});
// Merge Tag Reference command
var mergeTagReferenceContainer = document.getElementById('merge-tag-reference-container');
cmdm.add('open-merge-tag-reference', {
run(editor, sender) {
@ -287,7 +439,9 @@
},
});
// Simple warn notifier
var origWarn = console.warn;
toastr.options = {
closeButton: true,
@ -300,8 +454,10 @@
origWarn(msg);
};
$(document).ready(function() {
// Beautify tooltips
$(document).ready(function() {
$('*[title]').each(function() {
var el = $(this);
var title = el.attr('title').trim();
@ -310,65 +466,6 @@
el.attr('title', '');
}
});
// Remember testmail address
var isValidEmail = function(email) {
return /\S+@\S+\.\S+/.test(email);
};
var email = localStorage.getItem('testemail');
isValidEmail(email) && $('#test-form input[name=email]').val(email);
$(document).on('submit', '#test-form', function() {
var email = $('#test-form input[name=email]').val();
isValidEmail(email) && localStorage.setItem('testemail', email);
});
// Save and Close Buttons
window.bridge = window.bridge || {};
$('#mt-close').on('click', function() {
if (confirm('Unsaved changes will be lost. Close now?') === true) {
window.bridge.exit
? window.bridge.exit()
: window.location.href = '/{{type}}s/edit/{{resource.id}}?tab=template';
}
});
$('#mt-save').on('click', function() {
if ($(this).hasClass('busy')) {
return;
}
$(this).addClass('busy');
var html = getPreparedHtml();
$.post('/editorapi/update?type={{type}}&editor={{resource.editorName}}', {
id: {{resource.id}},
name: '{{resource.name}}',
{{#if resource.list}} list: {{resource.list}}, {{/if}}
html: html,
editorData: JSON.stringify({
template: '{{resource.editorData.template}}',
css: editor.getCss(),
html: editor.getHtml(),
style: editor.getStyle(),
components: editor.getComponents(),
}),
}, null, 'html')
.success(function() {
window.bridge.lastSavedHtml = html;
toastr.success('Sucessfully saved');
})
.fail(function(data) {
toastr.error(data.responseText || 'An error occured while saving the document');
})
.always(function() {
setTimeout(function() {
$(this).removeClass('busy');
}.bind(this), 500); // Don't save too fast
}.bind(this));
});
});
</script>

View file

@ -3,17 +3,28 @@
<head>
<meta charset="utf-8">
<title>GrapesJS Newsletter Editor</title>
<link rel="stylesheet" href="/grapejs/dist/css/grapes.min.css">
<link rel="stylesheet" href="/grapejs/dist/css/grapes.min.css?v=0.5.41">
<link rel="stylesheet" href="/grapejs/dist/css/toastr.min.css?v=2.1.3">
<link rel="stylesheet" href="/grapejs/dist/css/material.css">
<link rel="stylesheet" href="/grapejs/dist/css/tooltip.css">
<link rel="stylesheet" href="/grapejs/dist/css/toastr.min.css">
<link rel="stylesheet" href="/grapejs/dist/css/grapesjs-preset-newsletter.css">
<script src="/javascript/jquery-2.2.1.min.js"></script>
<script src="/grapejs/dist/js/grapes.min.js"></script>
<script src="/grapejs/dist/js/grapesjs-preset-newsletter.min.js"></script>
<script src="/grapejs/dist/js/toastr.min.js"></script>
<script src="/grapejs/dist/js/ajaxable.min.js"></script>
<script src="/grapejs/dist/js/grapes.min.js?v=0.5.41"></script>
<script src="/grapejs/dist/js/toastr.min.js?v=2.1.3"></script>
<script src="/grapejs/dist/js/ajaxable.min.js?v=0.2.3"></script>
{{#switch editor.mode}}
{{#case "mjml"}}
<link rel="stylesheet" href="/grapejs/dist/css/grapesjs-mjml.css?v=0.0.7">
<script src="/grapejs/dist/js/grapesjs-mjml.min.js?v=0.0.7"></script>
<script src="/grapejs/dist/js/grapesjs-preset-mjml.js"></script>
{{/case}}
{{#case "html"}}
<link rel="stylesheet" href="/grapejs/dist/css/grapesjs-preset-newsletter.css?v=0.2.3">
<script src="/grapejs/dist/js/grapesjs-preset-newsletter.min.js?v=0.2.3"></script>
{{/case}}
{{/switch}}
</head>
<body>