Basic support for Mosaico-based email templates.

This commit is contained in:
Tomas Bures 2018-04-02 11:58:32 +02:00
parent b5cdf57f72
commit 7b5642e911
38 changed files with 1271 additions and 751 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -61,7 +61,7 @@ export default class Login extends Component {
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/login');
if (submitSuccessful) {
const nextUrl = qs.parse(this.props.location.search).next || '/';
const nextUrl = qs.parse(this.props.location.search).next || mailtrainConfig.urlBase;
/* This ensures we get config for the authenticated user */
window.location = nextUrl;

View file

@ -25,7 +25,6 @@ import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birth
import styles from "./styles.scss";
import moment from "moment";
const FormState = {
Loading: 0,
LoadingWithNotice: 1,

View file

@ -0,0 +1,22 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import {
I18nextProvider,
} from 'react-i18next';
import i18n from './i18n';
import styles from "./mosaico.scss";
import { MosaicoSandbox } from './mosaico';
import { UntrustedContentRoot } from './untrusted';
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }>
<UntrustedContentRoot render={props => <MosaicoSandbox {...props} />} />
</I18nextProvider>,
document.getElementById('root')
);
};

166
client/src/lib/mosaico.js Normal file
View file

@ -0,0 +1,166 @@
'use strict';
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import {
I18nextProvider,
translate
} from 'react-i18next';
import i18n from './i18n';
import PropTypes from "prop-types";
import styles from "./mosaico.scss";
import mailtrainConfig from 'mailtrainConfig';
import { UntrustedContentHost } from './untrusted';
import {
Button,
Icon
} from "./bootstrap-components";
export const ResourceType = {
TEMPLATE: 'template',
CAMPAIGN: 'campaign'
}
@translate(null, { withRef: true })
export class MosaicoEditor extends Component {
constructor(props) {
super(props);
this.state = {
fullscreen: false
}
}
static propTypes = {
entityTypeId: PropTypes.string,
entity: PropTypes.object,
title: PropTypes.string,
onFullscreenAsync: PropTypes.func
}
async toggleFullscreenAsync() {
const fullscreen = !this.state.fullscreen;
this.setState({
fullscreen
});
await this.props.onFullscreenAsync(fullscreen);
}
async exportState() {
return await this.contentNode.ask('exportState');
}
render() {
const t = this.props.t;
const mosaicoData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id,
model: this.props.entity.data.model,
metadata: this.props.entity.data.metadata
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
{this.state.fullscreen && <img className={styles.logo} src="/public/mailtrain-notext.png"/>}
<div className={styles.title}>{this.props.title}</div>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync}><Icon icon="fullscreen"/></a>
</div>
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} contentProps={mosaicoData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={mosaicoData}/>
</div>
);
}
}
MosaicoEditor.prototype.exportState = async function() {
return await this.getWrappedInstance().exportState();
};
@translate(null, { withRef: true })
export class MosaicoSandbox extends Component {
constructor(props) {
super(props);
this.viewModel = null;
this.state = {
};
}
static propTypes = {
entityTypeId: PropTypes.string,
entityId: PropTypes.number,
model: PropTypes.object,
metadata: PropTypes.object
}
componentDidMount() {
const publicPath = '/public/mosaico';
if (!Mosaico.isCompatible()) {
alert('Update your browser!');
return;
}
const plugins = [...window.mosaicoPlugins];
plugins.push(viewModel => {
this.viewModel = viewModel;
});
// (Custom) HTML postRenderers
plugins.push(viewModel => {
viewModel.originalExportHTML = viewModel.exportHTML;
viewModel.exportHTML = () => {
let html = viewModel.originalExportHTML();
for (const portRender of window.mosaicoHTMLPostRenderers) {
html = postRender(html);
}
return html;
};
});
plugins.unshift(vm => {
// This is an override of the default paths in Mosaico
vm.logoPath = publicPath + '/img/mosaico32.png';
vm.logoUrl = '#';
});
const config = {
imgProcessorBackend: `/mosaico/img/${this.props.entityTypeId}/${this.props.entityId}`,
emailProcessorBackend: '/mosaico/dl/',
fileuploadConfig: {
url: `/mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`
},
strings: window.mosaicoLanguageStrings
};
const metadata = this.props.metadata;
const model = this.props.model;
const template = publicPath + '/templates/versafix-1/index.html';
const allPlugins = plugins.concat(window.mosaicoPlugins);
Mosaico.start(config, template, metadata, model, allPlugins);
}
async onMethodAsync(method, params) {
if (method === 'exportState') {
return {
html: this.viewModel.exportHTML(),
model: this.viewModel.exportJS(),
metadata: this.viewModel.metadata
};
}
}
render() {
return <div/>;
}
}
MosaicoSandbox.prototype.onMethodAsync = async function(method, params) {
return await this.getWrappedInstance().onMethodAsync(method, params);
};

View file

@ -0,0 +1,75 @@
$navbarHeight: 34px;
.editor {
.host {
height: 800px;
}
}
.editorFullscreen {
position: fixed;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
z-index: 1000;
background: white;
margin-top: $navbarHeight;
.navbar {
margin-top: -$navbarHeight;
}
.host {
height: 100%;
}
}
:global .mo-standalone {
top: 0px;
bottom: 0px;
width: 100%;
position: absolute;
}
.navbar {
background: #DE4320;
width: 100%;
height: $navbarHeight;
}
.logo {
float: left;
height: $navbarHeight;
padding: 5px 0 5px 10px;
filter: brightness(0) invert(1);
}
.title {
padding: 5px 0 5px 10px;
font-size: 18px;
font-family: sans-serif;
font-family: "Ubuntu",Tahoma,"Helvetica Neue",Helvetica,Arial,sans-serif;
font-weight: bold;
float: left;
color: white;
height: $navbarHeight;
}
.btn {
display: block;
float: right;
padding: 0px 15px;
line-height: $navbarHeight;
text-align: center;
color: white;
font-size: 14px;
font-weight: bold;
font-family: sans-serif;
cursor: pointer;
}
.btn:hover {
background-color: #b1381e;
color: white;
}

View file

@ -319,7 +319,6 @@ class SectionContent extends Component {
ensureAuthenticated() {
if (!mailtrainConfig.isAuthenticated) {
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
this.navigateTo('/account/login?next=' + encodeURIComponent(window.location.pathname));
}
}
@ -383,7 +382,7 @@ class Section extends Component {
render() {
return (
<Router>
<Router basename={mailtrainConfig.urlBase}>
<SectionContent root={this.props.root} structure={this.structure} />
</Router>
);

View file

@ -100,3 +100,15 @@
font-size: 20px;
color: #808080;
}
.untrustedContent {
border: 0px none;
width: 100%;
overflow: hidden;
}
.withElementInFullscreen {
height: 0px;
overflow: hidden;
}

View file

@ -13,7 +13,7 @@ import 'datatables.net-bs/css/dataTables.bootstrap.css';
import axios from './axios';
import { withPageHelpers } from '../lib/page'
import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import styles from "./styles.scss";
@ -394,7 +394,7 @@ class Table extends Component {
The reference to the table can be obtained by ref.
*/
Table.prototype.refresh = function() {
this.getWrappedInstance().refresh()
this.getWrappedInstance().refresh();
};
export {

View file

@ -12,7 +12,7 @@ import '../../vendor/fancytree/skin-bootstrap/ui.fancytree.min.css';
import './tree.css';
import axios from './axios';
import { withPageHelpers } from '../lib/page'
import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import styles from "./styles.scss";

227
client/src/lib/untrusted.js Normal file
View file

@ -0,0 +1,227 @@
'use strict';
import React, {Component} from "react";
import PropTypes from "prop-types";
import {translate} from "react-i18next";
import {requiresAuthenticatedUser, withPageHelpers} from "./page";
import {withAsyncErrorHandler, withErrorHandling} from "./error-handling";
import axios from "./axios";
import styles from "./styles.scss";
import {getTrustedUrl, getSandboxUrl} from "./urls";
import {Table} from "./table";
@translate(null, { withRef: true })
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export class UntrustedContentHost extends Component {
constructor(props) {
super(props);
this.refreshAccessTokenTimeout = null;
this.accessToken = null;
this.contentNodeIsLoaded = false;
this.state = {
hasAccessToken: false,
};
this.receiveMessageHandler = ::this.receiveMessage;
this.rpcCounter = 0;
this.rpcResolves = new Map();
}
static propTypes = {
contentSrc: PropTypes.string,
contentProps: PropTypes.object,
tokenMethod: PropTypes.string,
tokenParams: PropTypes.object,
className: PropTypes.string
}
isInitialized() {
return !!this.accessToken;
}
receiveMessage(evt) {
const msg = evt.data;
console.log(msg);
if (msg.type === 'initNeeded') {
if (this.isInitialized()) {
this.sendMessage('init', {
accessToken: this.accessToken,
contentProps: this.props.contentProps
});
}
} else if (msg.type === 'rpcResponse') {
const resolve = this.rpcResolves.get(msg.data.msgId);
resolve(msg.data.ret);
}
}
sendMessage(type, data) {
if (this.contentNodeIsLoaded) { // This is to avoid errors "common.js:45744 Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://localhost:8081') does not match the recipient window's origin ('http://localhost:3000')"
this.contentNode.contentWindow.postMessage({type, data}, getSandboxUrl(''));
}
}
async ask(method, params) {
if (this.contentNodeIsLoaded) {
this.rpcCounter += 1;
const msgId = this.rpcCounter;
this.sendMessage('rpcRequest', {
method,
params,
msgId
});
return await (new Promise((resolve, reject) => {
this.rpcResolves.set(msgId, resolve);
}));
}
}
@withAsyncErrorHandler
async refreshAccessToken() {
const result = await axios.post(getTrustedUrl('rest/restricted-access-token'), {
method: this.props.tokenMethod,
params: this.props.tokenParams
});
this.accessToken = result.data;
if (!this.state.hasAccessToken) {
this.setState({
hasAccessToken: true
})
}
this.sendMessage('accessToken', this.accessToken);
}
scheduleRefreshAccessToken() {
this.refreshAccessTokenTimeout = setTimeout(() => {
this.refreshAccessToken();
this.scheduleRefreshAccessToken();
}, 60 * 1000);
}
handleUpdate() {
if (this.isInitialized()) {
this.sendMessage('initAvailable');
}
if (!this.state.hasAccessToken) {
this.refreshAccessToken();
}
}
componentDidMount() {
this.scheduleRefreshAccessToken();
window.addEventListener('message', this.receiveMessageHandler, false);
this.handleUpdate();
}
componentWillUnmount() {
clearTimeout(this.refreshAccessTokenTimeout);
window.removeEventListener('message', this.receiveMessageHandler, false);
}
contentNodeLoaded() {
this.contentNodeIsLoaded = true;
}
render() {
const t = this.props.t;
return (
<iframe className={styles.untrustedContent + ' ' + this.props.className} ref={node => this.contentNode = node} src={getSandboxUrl(this.props.contentSrc)} onLoad={::this.contentNodeLoaded}> </iframe>
);
}
}
UntrustedContentHost.prototype.ask = async function(method, params) {
return await this.getWrappedInstance().ask(method, params);
};
@translate()
export class UntrustedContentRoot extends Component {
constructor(props) {
super(props);
this.state = {
initialized: false,
};
this.receiveMessageHandler = ::this.receiveMessage;
}
static propTypes = {
render: PropTypes.func
}
setAccessTokenCookie(token) {
document.cookie = 'restricted_access_token=' + token + '; expires=' + (new Date(Date.now()+60000)).toUTCString();
console.log(document.cookie);
}
async receiveMessage(evt) {
const msg = evt.data;
console.log(msg);
if (msg.type === 'initAvailable' && !this.state.initialized) {
this.sendMessage('initNeeded');
} else if (msg.type === 'init' && !this.state.initialized) {
this.setAccessTokenCookie(msg.data.accessToken);
this.setState({
initialized: true,
contentProps: msg.data.contentProps
});
} else if (msg.type === 'accessToken') {
this.setAccessTokenCookie(msg.data);
} else if (msg.type === 'rpcRequest') {
const ret = await this.contentNode.onMethodAsync(msg.data.method, msg.data.params);
this.sendMessage('rpcResponse', {msgId: msg.data.msgId, ret});
}
}
sendMessage(type, data) {
window.parent.postMessage({type, data}, getTrustedUrl(''));
}
componentDidMount() {
window.addEventListener('message', this.receiveMessageHandler, false);
this.sendMessage('initNeeded');
}
componentWillUnmount() {
window.removeEventListener('message', this.receiveMessageHandler, false);
}
render() {
const t = this.props.t;
const props = {
...this.state.contentProps,
ref: node => this.contentNode = node
};
if (this.state.initialized) {
return this.props.render(props);
} else {
return (
<div>
{t('Loading...')}
</div>
);
}
}
}

37
client/src/lib/urls.js Normal file
View file

@ -0,0 +1,37 @@
'use strict';
import mailtrainConfig from "mailtrainConfig";
let urlBase;
let sandboxUrlBase;
if (mailtrainConfig.urlBase.startsWith('/')) {
urlBase = window.location.protocol + '//' + window.location.hostname + ':' + mailtrainConfig.port + mailtrainConfig.urlBase;
} else {
urlBase = mailtrainConfig.urlBase
}
if (mailtrainConfig.sandboxUrlBase) {
if (mailtrainConfig.urlBase.startsWith('/')) {
sandboxUrlBase = window.location.protocol + '//' + window.location.hostname + ':' + mailtrainConfig.sandboxPort + mailtrainConfig.sandboxUrlBase;
} else {
sandboxUrlBase = mailtrainConfig.sandboxUrlBase
}
} else {
const loc = document.createElement("a");
loc.href = urlBase;
sandboxUrlBase = loc.protocol + '//' + loc.hostname + ':' + mailtrainConfig.sandboxPort + loc.pathname;
}
function getTrustedUrl(path) {
return urlBase + path;
}
function getSandboxUrl(path) {
return sandboxUrlBase + path;
}
export {
getTrustedUrl,
getSandboxUrl
}

View file

@ -1,12 +1,7 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import qs from 'querystringify';
import { Section } from '../lib/page';
import ListsList from './List';
import ListsCUD from './CUD';
import FormsList from './forms/List';

View file

@ -1,118 +0,0 @@
'use strict';
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import {
I18nextProvider,
translate
} from 'react-i18next';
import i18n from '../lib/i18n';
import PropTypes from "prop-types";
import styles from "./styles.scss";
const ResourceType = {
TEMPLATE: 'template',
CAMPAIGN: 'campaign'
}
@translate()
class MosaicoEditor extends Component {
constructor(props) {
super(props);
this.viewModel = null;
this.state = {
entityTypeId: ResourceType.TEMPLATE, // FIXME
entityId: 13 // FIXME
}
}
static propTypes = {
//structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
}
async onClose(evt) {
const t = this.props.t;
evt.preventDefault();
evt.stopPropagation();
if (confirm(t('Unsaved changes will be lost. Close now?'))) {
window.location.href = `/${this.state.entityTypeId}s/${this.state.entityId}/edit`;
}
}
componentDidMount() {
const publicPath = '/public/mosaico';
if (!Mosaico.isCompatible()) {
alert('Update your browser!');
return;
}
const plugins = window.mosaicoPlugins;
plugins.push(viewModel => {
this.viewModel = viewModel;
});
// (Custom) HTML postRenderers
plugins.push(viewModel => {
viewModel.originalExportHTML = viewModel.exportHTML;
viewModel.exportHTML = () => {
let html = viewModel.originalExportHTML();
for (const portRender of window.mosaicoHTMLPostRenderers) {
html = postRender(html);
}
return html;
};
});
plugins.unshift(vm => {
// This is a fix for the use of hardcoded path in Mosaico
vm.logoPath = publicPath + '/img/mosaico32.png'
});
const config = {
imgProcessorBackend: `/mosaico/img/${this.state.entityTypeId}/${this.state.entityId}`,
emailProcessorBackend: '/mosaico/dl/',
titleToken: "MOSAICO Responsive Email Designer",
fileuploadConfig: {
url: `/mosaico/upload/${this.state.entityTypeId}/${this.state.entityId}`
},
strings: window.mosaicoLanguageStrings
};
const metadata = undefined;
const model = undefined;
const template = publicPath + '/templates/versafix-1/index.html';
const allPlugins = plugins.concat(window.mosaicoPlugins);
Mosaico.start(config, template, metadata, model, allPlugins);
}
componentDidUpdate() {
}
render() {
const t = this.props.t;
return (
<div className={styles.navbar}>
<img className={styles.logo} src="/public/mailtrain-header.png"/>
<a className={styles.btn} onClick={::this.onClose}>{t('CLOSE')}</a>
<a className={styles.btn}><span></span></a>
</div>
);
}
}
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><MosaicoEditor /></I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -1,35 +0,0 @@
:global .mo-standalone {
top: 34px;
bottom: 0px;
width: 100%;
position: absolute;
}
.navbar {
background: #DE4320;
overflow: hidden;
height: 34px;
top: -34px;
position: absolute;
width: 100%;
}
.logo {
height: 24px;
padding: 5px 0 5px 10px;
filter: brightness(0) invert(1);
}
.btn {
display: block;
float: right;
width: 150px;
line-height: 34px;
text-align: center;
color: white;
font-size: 14px;
font-weight: bold;
font-family: sans-serif;
cursor: pointer;
border-left: 1px solid #972E15;
}

View file

@ -1,11 +1,6 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page';
import CUD from './CUD';
import List from './List';
import Share from '../shares/Share';

View file

@ -1,11 +1,6 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page';
import ReportsCUD from './CUD';
import ReportsList from './List';
import ReportsView from './View';
@ -13,7 +8,7 @@ import ReportsOutput from './Output';
import ReportTemplatesCUD from './templates/CUD';
import ReportTemplatesList from './templates/List';
import Share from '../shares/Share';
import { ReportState } from '../../../shared/reports';
import {ReportState} from '../../../shared/reports';
import mailtrainConfig from 'mailtrainConfig';

View file

@ -139,7 +139,7 @@ class Root extends Component {
async logout() {
await axios.post('/rest/logout');
window.location = '/';
window.location = mailtrainConfig.urlBase;
}
render() {

View file

@ -39,7 +39,8 @@ export default class CUD extends Component {
this.templateTypes = getTemplateTypes(props.t);
this.state = {
showMergeTagReference: false
showMergeTagReference: false,
elementInFullscreen: false
};
this.initForm();
@ -66,7 +67,8 @@ export default class CUD extends Component {
namespace: mailtrainConfig.user.namespace,
type: mailtrainConfig.editors[0],
text: '',
html: ''
html: '',
data: {}
});
}
}
@ -92,6 +94,11 @@ export default class CUD extends Component {
async submitHandler() {
const t = this.props.t;
if (this.props.entity) {
const typeKey = this.getFormValue('type');
await this.templateTypes[typeKey].htmlEditorBeforeSave(this);
}
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
@ -120,6 +127,9 @@ export default class CUD extends Component {
}
async extractPlainText() {
const typeKey = this.getFormValue('type');
await this.templateTypes[typeKey].htmlEditorBeforeSave(this);
const html = this.getFormValue('html');
if (!html) {
alert('Missing HTML content');
@ -145,6 +155,12 @@ export default class CUD extends Component {
});
}
async setElementInFullscreen(elementInFullscreen) {
this.setState({
elementInFullscreen
});
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
@ -241,7 +257,7 @@ export default class CUD extends Component {
</div>}
</AlignedRow>
{this.templateTypes[typeKey].form}
{this.templateTypes[typeKey].getHTMLEditor(this)}
<ACEEditor id="text" height="400px" mode="text" label={t('Template content (plain text)')} help={<Trans>To extract the text from HTML click <ActionLink onClickAsync={::this.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}/>
</div>
@ -249,7 +265,7 @@ export default class CUD extends Component {
return (
<div>
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
{canDelete &&
<DeleteModalDialog
stateOwner={this}

View file

@ -3,17 +3,27 @@
import React from "react";
import {
ACEEditor,
AlignedRow,
CKEditor
} from "../lib/form";
import 'brace/mode/text';
import 'brace/mode/html'
import {MosaicoEditor, ResourceType} from "../lib/mosaico";
export function getTemplateTypes(t) {
const templateTypes = {};
templateTypes.mosaico = {
typeName: t('Mosaico')
typeName: t('Mosaico'),
getHTMLEditor: owner => <AlignedRow label={t('Template content (HTML)')}><MosaicoEditor ref={node => owner.editorNode = node} entity={owner.props.entity} entityTypeId={ResourceType.TEMPLATE} title={t('Mosaico Template Designer')} onFullscreenAsync={::owner.setElementInFullscreen}/></AlignedRow>,
htmlEditorBeforeSave: async owner => {
const {html, metadata, model} = await owner.editorNode.exportState();
owner.updateFormValue('html', html);
owner.updateFormValue('data', {metadata, model});
}
};
templateTypes.grapejs = {
@ -22,12 +32,14 @@ export function getTemplateTypes(t) {
templateTypes.ckeditor = {
typeName: t('CKEditor'),
form: <CKEditor id="html" height="600px" label={t('Template content (HTML)')}/>
getHTMLEditor: owner => <CKEditor id="html" height="600px" label={t('Template content (HTML)')}/>,
htmlEditorBeforeSave: async owner => {}
};
templateTypes.codeeditor = {
typeName: t('Code Editor'),
form: <ACEEditor id="html" height="600px" mode="html" label={t('Template content (HTML)')}/>
getHTMLEditor: owner => <ACEEditor id="html" height="600px" mode="html" label={t('Template content (HTML)')}/>,
htmlEditorBeforeSave: async owner => {}
};
templateTypes.mjml = {

View file

@ -1,11 +1,7 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page';
import TemplatesCUD from './CUD';
import TemplatesList from './List';
import Share from '../shares/Share';

View file

@ -4,7 +4,7 @@ const path = require('path');
module.exports = {
entry: {
root: ['babel-polyfill', './src/root.js'],
mosaico: ['babel-polyfill', './src/mosaico/root.js'],
mosaico: ['babel-polyfill', './src/lib/mosaico-sandbox-root.js'],
},
output: {
library: 'MailtrainReactBody',