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

This commit is contained in:
Tomas Bures 2017-06-03 07:50:09 +02:00
commit d13fc65ce2
32 changed files with 1190 additions and 295 deletions

1
.gitignore vendored
View file

@ -26,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

@ -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,8 @@ let config = require('config');
let mysql = require('mysql');
let redis = require('redis');
let Lock = require('redfour');
let stringifyDate = require('json-stringify-date');
let tools = require('./tools');
module.exports = mysql.createPool(config.mysql);
if (config.redis && config.redis.enabled) {
@ -33,7 +35,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) => {
@ -41,7 +43,7 @@ if (config.redis && config.redis.enabled) {
return setImmediate(() => callback());
}
module.exports.redis.multi().
lpush('mailtrain:cache:' + key, JSON.stringify(value)).
lpush('mailtrain:cache:' + key, stringifyDate.stringify(value)).
expire('mailtrain:cache:' + key, 24 * 3600).
exec(err => callback(err));
};
@ -52,7 +54,7 @@ if (config.redis && config.redis.enabled) {
return callback(err);
}
try {
value = JSON.parse(value);
value = stringifyDate.parse(value);
} catch (E) {
return callback(E);
}
@ -76,9 +78,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

@ -458,7 +458,7 @@ module.exports.getRow = (fieldList, values, useDate, showAll, onlyExisting) => {
value: Number(valueList[field.column]) || 0,
visible: !!field.visible,
mergeTag: field.key,
mergeValue: Number(valueList[field.column]) || Number(field.defaultValue) || 0,
mergeValue: (Number(valueList[field.column]) || Number(field.defaultValue) || 0).toString(),
['type' + (field.type || '').toString().trim().replace(/(?:^|\-)([a-z])/g, (m, c) => c.toUpperCase())]: true
};
row.push(item);

View file

@ -2,6 +2,7 @@
let db = require('../db');
let shortid = require('shortid');
let striptags = require('striptags');
let tools = require('../tools');
let helpers = require('../helpers');
let fields = require('./fields');
@ -136,6 +137,8 @@ module.exports.insert = (listId, meta, subscriptionData, callback) => {
values.push(field.value);
});
values = values.map(v => typeof v === 'string' ? striptags(v) : v);
db.getConnection((err, connection) => {
if (err) {
return callback(err);
@ -355,7 +358,7 @@ module.exports.getWithMergeTags = (listId, cid, callback) => {
TIMEZONE: subscription.tz || ''
};
fields.getRow(fieldList, subscription, true, true).forEach(field => {
fields.getRow(fieldList, subscription, false, true).forEach(field => {
if (field.mergeTag) {
subscription.mergeTags[field.mergeTag] = field.mergeValue || '';
}
@ -420,6 +423,8 @@ module.exports.update = (listId, cid, updates, allowEmail, callback) => {
return callback(null, false);
}
values = values.map(v => typeof v === 'string' ? striptags(v) : v);
db.getConnection((err, connection) => {
if (err) {
return callback(err);

View file

@ -13,6 +13,7 @@ let he = require('he');
let _ = require('./translate')._;
let util = require('util');
let createDOMPurify = require('dompurify');
let htmlToText = require('html-to-text');
let blockedUsers = ['abuse', 'admin', 'billing', 'compliance', 'devnull', 'dns', 'ftp', 'hostmaster', 'inoc', 'ispfeedback', 'ispsupport', 'listrequest', 'list', 'maildaemon', 'noc', 'noreply', 'noreply', 'null', 'phish', 'phishing', 'postmaster', 'privacy', 'registrar', 'root', 'security', 'spam', 'support', 'sysadmin', 'tech', 'undisclosedrecipients', 'unsubscribe', 'usenet', 'uucp', 'webmaster', 'www'];
@ -200,17 +201,24 @@ function formatMessage(serviceUrl, campaign, list, subscription, message, filter
return links[key];
}
if (subscription.mergeTags.hasOwnProperty(key)) {
return isHTML ? he.encode((subscription.mergeTags[key] || ''), {
useNamedReferences: true
}) : subscription.mergeTags[key];
let value = (subscription.mergeTags[key] || '').toString();
let containsHTML = /<[a-z][\s\S]*>/.test(value);
return isHTML ? he.encode((containsHTML ? value : value.replace(/(?:\r\n|\r|\n)/g, '<br/>')), {
useNamedReferences: true,
allowUnsafeSymbols: true
}) : (containsHTML ? htmlToText.fromString(value) : value);
}
return false;
};
return message.replace(/\[([a-z0-9_]+)(?:\/([^\]]+))?\]/ig, (match, identifier, fallback) => {
identifier = identifier.toUpperCase();
let value = (getValue(identifier) || fallback || '').trim();
return value ? filter(value) : match;
let value = getValue(identifier);
if (value === false) {
return match;
}
value = (value || fallback || '').trim();
return filter(value);
});
}
@ -310,4 +318,3 @@ function mergeTemplateIntoLayout(template, layout, callback) {
return done(template, layout);
}
}

View file

@ -81,6 +81,7 @@
"isemail": "^2.2.1",
"jquery-file-upload-middleware": "^0.1.8",
"jsdom": "^9.12.0",
"json-stringify-date": "^0.1.4",
"juice": "^4.0.2",
"knex": "^0.13.0",
"libmime": "^3.1.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

@ -545,7 +545,7 @@ router.post('/clicked/ajax/:id/:linkId', (req, res) => {
let campaignCid = campaign.cid;
let listCid = list.cid;
let columns = ['#', 'email', 'first_name', 'last_name', 'campaign_tracker__' + campaign.id + '`.`created', 'count'];
let columns = ['#', 'email', 'first_name', 'last_name', 'campaign_tracker__' + campaign.id + '.created', 'count'];
campaigns.filterClickedSubscribers(campaign, linkId, req.body, columns, (err, data, total, filteredTotal) => {
if (err) {
return res.json({
@ -634,7 +634,7 @@ router.post('/status/ajax/:id/:status', (req, res) => {
let campaignCid = campaign.cid;
let listCid = list.cid;
let columns = ['#', 'email', 'first_name', 'last_name', 'campaign__' + campaign.id + '`.`updated'];
let columns = ['#', 'email', 'first_name', 'last_name', 'campaign__' + campaign.id + '.updated'];
campaigns.filterStatusSubscribers(campaign, status, req.body, columns, (err, data, total, filteredTotal) => {
if (err) {
return res.json({
@ -683,7 +683,7 @@ router.post('/clicked/ajax/:id/:linkId', (req, res) => {
let campaignCid = campaign.cid;
let listCid = list.cid;
let columns = ['#', 'email', 'first_name', 'last_name', 'campaign_tracker__' + campaign.id + '`.`created', 'count'];
let columns = ['#', 'email', 'first_name', 'last_name', 'campaign_tracker__' + campaign.id + '.created', 'count'];
campaigns.filterClickedSubscribers(campaign, linkId, req.body, columns, (err, data, total, filteredTotal) => {
if (err) {
return res.json({

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 ?
{
try {
resource.editorData = JSON.parse(resource.editorData);
} catch (err) {
resource.editorData = {
template: req.query.template || 'demo'
} :
JSON.parse(resource.editorData);
}
}
if (!resource.html && !resource.editorData.html) {
if (!resource.html && !resource.editorData.html && !resource.editorData.mjml) {
const base = path.join(__dirname, '..', 'public', 'grapejs', 'templates', resource.editorData.template);
try {
let file = path.join(__dirname, '..', 'public', 'grapejs', 'templates', resource.editorData.template, 'index.html');
resource.html = fs.readFileSync(file, 'utf8');
resource.editorData.mjml = fs.readFileSync(path.join(base, 'index.mjml'), 'utf8');
} catch (err) {
resource.html = err.message || 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

@ -257,7 +257,7 @@ router.post('/status/ajax/:id', (req, res) => {
});
}
let columns = ['#', 'email', 'first_name', 'last_name', 'trigger__' + trigger.id + '`.`created'];
let columns = ['#', 'email', 'first_name', 'last_name', 'trigger__' + trigger.id + '.created'];
triggers.filterSubscribers(trigger, req.body, columns, (err, data, total, filteredTotal) => {
if (err) {
return res.json({

View file

@ -332,7 +332,7 @@ function formatMessage(message, callback) {
};
let encryptionKeys = [];
fields.getRow(fieldList, message.subscription, true, true).forEach(field => {
fields.getRow(fieldList, message.subscription, false, true).forEach(field => {
if (field.mergeTag) {
message.subscription.mergeTags[field.mergeTag] = field.mergeValue || '';
}
@ -466,119 +466,129 @@ 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;
}
// find an unsent message
findUnsent((err, message) => {
if (err) {
log.error('Mail', err.stack);
setTimeout(getNext, mailing_timeout);
return;
}
if (!message) {
setTimeout(getNext, mailing_timeout);
return;
}
isThrottled = true;
//log.verbose('Mail', 'Found new message to be delivered: %s', message.subscription.cid);
// format message to nodemailer message format
formatMessage(message, (err, mail) => {
mailer.transport.checkThrottling(() => {
isThrottled = false;
// find an unsent message
findUnsent((err, message) => {
if (err) {
log.error('Mail', err.stack);
setTimeout(getNext, mailing_timeout);
return;
}
if (!message) {
setTimeout(getNext, mailing_timeout);
return;
}
blacklist.isblacklisted(mail.to.address, (err, blacklisted) => {
if (err) {
log.error('Mail', err);
setTimeout(getNext, mailing_timeout);
return;
}
if (!blacklisted) {
let tryCount = 0;
let trySend = () => {
tryCount++;
// log.verbose('Mail', 'Found new message to be delivered: %s', message.subscription.cid);
// format message to nodemailer message format
formatMessage(message, (err, mail) => {
if (err) {
log.error('Mail', err.stack);
setTimeout(getNext, mailing_timeout);
return;
}
// send the message
mailer.transport.sendMail(mail, (err, info) => {
if (err) {
log.error('Mail', err.stack);
if (err.responseCode && err.responseCode >= 400 && err.responseCode < 500 && tryCount <= 5) {
// temporary error, try again
return setTimeout(trySend, tryCount * 1000);
}
}
blacklist.isblacklisted(mail.to.address, (err, blacklisted) => {
if (err) {
log.error('Mail', err);
setTimeout(getNext, mailing_timeout);
return;
}
let status = err ? 2 : 1;
let response = err && (err.response || err.message) || info.response || info.messageId;
let responseId = response.split(/\s+/).pop();
if (!blacklisted) {
let tryCount = 0;
let trySend = () => {
tryCount++;
// send the message
mailer.transport.sendMail(mail, (err, info) => {
if (err) {
log.error('Mail', err.stack);
if (err.responseCode && err.responseCode >= 400 && err.responseCode < 500 && tryCount <= 5) {
// temporary error, try again
return setTimeout(trySend, tryCount * 1000);
}
}
let status = err ? 2 : 1;
let response = err && (err.response || err.message) || info.response || info.messageId;
let responseId = response.split(/\s+/).pop();
db.getConnection((err, connection) => {
if (err) {
log.error('Mail', err.stack);
return;
}
let query = 'UPDATE `campaigns` SET `delivered`=`delivered`+1 ' + (status === 2 ? ', `bounced`=`bounced`+1 ' : '') + ' WHERE id=? LIMIT 1';
connection.query(query, [message.campaignId], err => {
if (err) {
log.error('Mail', err.stack);
}
let query = 'UPDATE `campaign__' + message.campaignId + '` SET status=?, response=?, response_id=?, updated=NOW() WHERE id=? LIMIT 1';
connection.query(query, [status, response, responseId, message.id], err => {
connection.release();
if (err) {
log.error('Mail', err.stack);
} else {
// log.verbose('Mail', 'Message sent and status updated for %s', message.subscription.cid);
}
});
});
});
});
};
setImmediate(trySend);
} else {
db.getConnection((err, connection) => {
if (err) {
log.error('Mail', err.stack);
log.error('Mail', err);
return;
}
let query = 'UPDATE `campaigns` SET `delivered`=`delivered`+1 ' + (status === 2 ? ', `bounced`=`bounced`+1 ' : '') + ' WHERE id=? LIMIT 1';
let query = 'UPDATE `campaigns` SET `blacklisted`=`blacklisted`+1 WHERE id=? LIMIT 1';
connection.query(query, [message.campaignId], err => {
if (err) {
log.error('Mail', err.stack);
log.error('Mail', err);
}
let query = 'UPDATE `campaign__' + message.campaignId + '` SET status=?, response=?, response_id=?, updated=NOW() WHERE id=? LIMIT 1';
connection.query(query, [status, response, responseId, message.id], err => {
connection.query(query, [5, 'blacklisted', 'blacklisted', message.id], err => {
connection.release();
if (err) {
log.error('Mail', err.stack);
} else {
// log.verbose('Mail', 'Message sent and status updated for %s', message.subscription.cid);
log.error('Mail', err);
}
});
});
});
});
};
setImmediate(trySend);
} else {
db.getConnection((err, connection) => {
if (err) {
log.error('Mail', err);
return;
}
let query = 'UPDATE `campaigns` SET `blacklisted`=`blacklisted`+1 WHERE id=? LIMIT 1';
connection.query(query, [message.campaignId], err => {
if (err) {
log.error('Mail', err);
}
let query = 'UPDATE `campaign__' + message.campaignId + '` SET status=?, response=?, response_id=?, updated=NOW() WHERE id=? LIMIT 1';
connection.query(query, [5, 'blacklisted', 'blacklisted', message.id], err => {
connection.release();
if (err) {
log.error('Mail', err);
}
});
});
});
}
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);
});
};

File diff suppressed because one or more lines are too long

View file

@ -436,4 +436,75 @@ suite('Subscription use-cases', () => {
});
});
useCase('A subscriber address can be changed to an address which has been previously unsubscribed. #222', async () => {
const page = getPage(config.lists.l1);
const oldSubscription = await subscriptionExistsPrecondition(config.lists.l1, {
email: generateEmail(),
firstName: 'old first name',
lastName: 'old last name'
});
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(oldSubscription.email);
});
const newSubscription = await subscriptionExistsPrecondition(config.lists.l1, {
email: generateEmail(),
firstName: 'new first name'
});
await step('User clicks the manage subscription button.', async () => {
await page.mailSubscriptionConfirmed.click('manageLink');
});
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 the email address of the original subscription and submits the form.', async () => {
await page.webManageAddress.setValue('emailNewInput', oldSubscription.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(oldSubscription.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. The form does not contain data from the old subscription.', 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(oldSubscription.email);
expect(await page.webManage.getValue('firstNameInput')).to.equal(newSubscription.firstName);
expect(await page.webManage.getValue('lastNameInput')).to.equal('');
});
await step('System sends an email with subscription confirmation.', async () => {
await page.mailSubscriptionConfirmed.fetchMail(oldSubscription.email);
});
});
});

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({
height: '100%',
storageManager: {
type: 'none'
},
assetManager: {
assets: [],
upload: '/editorapi/upload?type={{type}}&id={{resource.id}}&editor={{resource.editorName}}',
uploadText: 'Drop images here or click to upload',
},
container : '#gjs',
fromElement: true,
plugins: ['gjs-preset-newsletter'],
pluginsOpts: {
'gjs-preset-newsletter': {
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={{editor.name}}',
uploadText: 'Drop images here or click to upload',
},
container : '#gjs',
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,68 +204,161 @@
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,
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
});
});
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;
}
// 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) {
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();
mdlDialog.className += ' ' + mdlClass;
testContainer.style.display = 'block';
md.setTitle('Test your Newsletter');
md.setContent(testContainer);
md.open();
md.getModel().once('change:open', function() {
mdlDialog.className = mdlDialog.className.replace(mdlClass, '');
//clean status
})
// TODO: Show a spinner
getPreparedHtml(function(html) {
sender.set('active', 0);
var modalContent = md.getContentEl();
var mdlDialog = document.querySelector('.gjs-mdl-dialog');
testContentEl.value = html;
mdlDialog.className += ' ' + mdlClass;
testContainer.style.display = 'block';
md.setTitle('Test your Newsletter');
md.setContent(testContainer);
md.open();
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);
};
// Beautify tooltips
$(document).ready(function() {
// Beautify tooltips
$('*[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>