diff --git a/app-builder.js b/app-builder.js
index 6d34202b..56171622 100644
--- a/app-builder.js
+++ b/app-builder.js
@@ -45,6 +45,7 @@ const sharesRest = require('./routes/rest/shares');
const segmentsRest = require('./routes/rest/segments');
const subscriptionsRest = require('./routes/rest/subscriptions');
const templatesRest = require('./routes/rest/templates');
+const mosaicoTemplatesRest = require('./routes/rest/mosaico-templates');
const blacklistRest = require('./routes/rest/blacklist');
const editorsRest = require('./routes/rest/editors');
const filesRest = require('./routes/rest/files');
@@ -279,6 +280,7 @@ function createApp(trusted) {
app.use('/rest', segmentsRest);
app.use('/rest', subscriptionsRest);
app.use('/rest', templatesRest);
+ app.use('/rest', mosaicoTemplatesRest);
app.use('/rest', blacklistRest);
app.use('/rest', editorsRest);
app.use('/rest', filesRest);
diff --git a/client/src/reports/root.js b/client/src/reports/root.js
index de6c33e4..f9bb7006 100644
--- a/client/src/reports/root.js
+++ b/client/src/reports/root.js
@@ -61,7 +61,7 @@ function getMenus(t) {
title: t('Create'),
panelRender: props =>
},
- 'templates': {
+ templates: {
title: t('Templates'),
link: '/reports/templates',
panelComponent: ReportTemplatesList,
diff --git a/client/src/templates/List.js b/client/src/templates/List.js
index da70dc36..f5852611 100644
--- a/client/src/templates/List.js
+++ b/client/src/templates/List.js
@@ -29,13 +29,22 @@ export default class List extends Component {
createTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createTemplate']
+ },
+ createMosaicoTemplate: {
+ entityTypeId: 'namespace',
+ requiredOperations: ['createMosaicoTemplate']
+ },
+ viewMosaicoTemplate: {
+ entityTypeId: 'mosaicoTemplate',
+ requiredOperations: ['view']
}
};
const result = await axios.post('/rest/permissions-check', request);
this.setState({
- createPermitted: result.data.createTemplate
+ createPermitted: result.data.createTemplate,
+ mosaicoTemplatesPermitted: result.data.createMosaicoTemplate || result.data.viewMosaicoTemplate
});
}
@@ -85,14 +94,16 @@ export default class List extends Component {
return (
- {this.state.createPermitted &&
-
-
+
+ {this.state.createPermitted &&
+
+ }
+ {this.state.mosaicoTemplatesPermitted &&
-
- }
+ }
+
-
{t(' Templates')}
+
{t('Templates')}
diff --git a/client/src/templates/mosaico/CUD.js b/client/src/templates/mosaico/CUD.js
new file mode 100644
index 00000000..b8599b50
--- /dev/null
+++ b/client/src/templates/mosaico/CUD.js
@@ -0,0 +1,192 @@
+'use strict';
+
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { translate, Trans } from 'react-i18next';
+import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../../lib/page'
+import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ButtonRow, Button } from '../../lib/form';
+import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
+import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
+import {DeleteModalDialog} from "../../lib/modals";
+
+import { versafix } from "../../../../shared/mosaico-templates";
+import { getTemplateTypes } from "./helpers";
+
+@translate()
+@withForm
+@withPageHelpers
+@withErrorHandling
+@requiresAuthenticatedUser
+export default class CUD extends Component {
+ constructor(props) {
+ super(props);
+
+ this.templateTypes = getTemplateTypes(props.t);
+
+ this.state = {};
+
+ this.initForm();
+ }
+
+ static propTypes = {
+ action: PropTypes.string.isRequired,
+ wizard: PropTypes.string,
+ entity: PropTypes.object
+ }
+
+ @withAsyncErrorHandler
+ async loadFormValues() {
+ await this.getFormValuesFromURL(`/rest/mosaico-templates/${this.props.entity.id}`, data => {
+ this.templateTypes[data.type].afterLoad(data);
+ });
+ }
+
+ componentDidMount() {
+ if (this.props.entity) {
+ this.getFormValuesFromEntity(this.props.entity, data => {
+ this.templateTypes[data.type].afterLoad(data);
+ });
+
+ } else {
+ const wizard = this.props.wizard;
+
+ if (wizard === 'versafix') {
+ this.populateFormValues({
+ name: '',
+ description: '',
+ namespace: mailtrainConfig.user.namespace,
+ type: 'html',
+ html: versafix
+ });
+
+ } else {
+ this.populateFormValues({
+ name: '',
+ description: '',
+ namespace: mailtrainConfig.user.namespace,
+ type: 'html',
+ html: ''
+ });
+ }
+ }
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+
+ if (!state.getIn(['name', 'value'])) {
+ state.setIn(['name', 'error'], t('Name must not be empty'));
+ } else {
+ state.setIn(['name', 'error'], null);
+ }
+
+ if (!state.getIn(['type', 'value'])) {
+ state.setIn(['type', 'error'], t('Type must be selected'));
+ } else {
+ state.setIn(['type', 'error'], null);
+ }
+
+ validateNamespace(t, state);
+ }
+
+ async submitAndStay() {
+ await this.formHandleChangedError(async () => await this.doSubmit(true));
+ }
+
+ async submitAndLeave() {
+ await this.formHandleChangedError(async () => await this.doSubmit(false));
+ }
+
+ async doSubmit(stay) {
+ const t = this.props.t;
+
+ let sendMethod, url;
+ if (this.props.entity) {
+ sendMethod = FormSendMethod.PUT;
+ url = `/rest/mosaico-templates/${this.props.entity.id}`
+ } else {
+ sendMethod = FormSendMethod.POST;
+ url = '/rest/mosaico-templates'
+ }
+
+ this.disableForm();
+ this.setFormStatusMessage('info', t('Saving ...'));
+
+ const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
+ this.templateTypes[data.type].beforeSave(data);
+ });
+
+ if (submitSuccessful) {
+ if (stay) {
+ await this.loadFormValues();
+ this.enableForm();
+ this.setFormStatusMessage('success', t('Mosaico template saved'));
+ } else {
+ this.navigateToWithFlashMessage('/templates/mosaico', 'success', t('Mosaico template saved'));
+ }
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+ const isEdit = !!this.props.entity;
+ const canDelete = isEdit && this.props.entity.permissions.includes('delete');
+
+ const typeKey = this.getFormValue('type');
+ let form = null;
+ if (typeKey) {
+ form = this.templateTypes[typeKey].getForm(this);
+ }
+
+ const typeOptions = [];
+ for (const type of ['html', 'mjml']) {
+ typeOptions.push({
+ key: type,
+ label: this.templateTypes.typeName
+ });
+ }
+
+ return (
+
+ {canDelete &&
+
+ }
+
+
{isEdit ? t('Edit Mosaico Template') : t('Create Mosaico Template')}
+
+
+
+ );
+ }
+}
diff --git a/client/src/templates/mosaico/List.js b/client/src/templates/mosaico/List.js
new file mode 100644
index 00000000..c3a29ece
--- /dev/null
+++ b/client/src/templates/mosaico/List.js
@@ -0,0 +1,97 @@
+'use strict';
+
+import React, { Component } from 'react';
+import { translate } from 'react-i18next';
+import {DropdownMenu, Icon} from '../../lib/bootstrap-components';
+import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, DropdownLink } from '../../lib/page';
+import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
+import { Table } from '../../lib/table';
+import axios from '../../lib/axios';
+import moment from 'moment';
+import { getTemplateTypes } from './helpers';
+
+
+@translate()
+@withPageHelpers
+@withErrorHandling
+@requiresAuthenticatedUser
+export default class List extends Component {
+ constructor(props) {
+ super(props);
+
+ this.templateTypes = getTemplateTypes(props.t);
+
+ this.state = {};
+ }
+
+ @withAsyncErrorHandler
+ async fetchPermissions() {
+ const request = {
+ createMosaicoTemplate: {
+ entityTypeId: 'namespace',
+ requiredOperations: ['createMosaicoTemplate']
+ }
+ };
+
+ const result = await axios.post('/rest/permissions-check', request);
+
+ this.setState({
+ createPermitted: result.data.createMosaicoTemplate
+ });
+ }
+
+ componentDidMount() {
+ this.fetchPermissions();
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const columns = [
+ { data: 1, title: t('Name') },
+ { data: 2, title: t('Description') },
+ { data: 3, title: t('Type'), render: data => this.templateTypes[data].typeName },
+ { data: 4, title: t('Created'), render: data => moment(data).fromNow() },
+ { data: 5, title: t('Namespace') },
+ {
+ actions: data => {
+ const actions = [];
+ const perms = data[6];
+
+ if (perms.includes('edit')) {
+ actions.push({
+ label: ,
+ link: `/templates/mosaico/${data[0]}/edit`
+ });
+ }
+
+ if (perms.includes('share')) {
+ actions.push({
+ label: ,
+ link: `/templates/mosaico/${data[0]}/share`
+ });
+ }
+
+ return actions;
+ }
+ }
+ ];
+
+ return (
+
+ {this.state.createPermitted &&
+
+
+ {t('Blank')}
+ {t('Versafix One')}
+
+
+ }
+
+
{t('Mosaico Templates')}
+
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/templates/mosaico/helpers.js b/client/src/templates/mosaico/helpers.js
new file mode 100644
index 00000000..ad4f774c
--- /dev/null
+++ b/client/src/templates/mosaico/helpers.js
@@ -0,0 +1,48 @@
+'use strict';
+
+import React from "react";
+import {ACEEditor} from "../../lib/form";
+import 'brace/mode/html'
+import 'brace/mode/xml'
+
+export function getTemplateTypes(t) {
+ const templateTypes = {};
+
+ function clearBeforeSend(data) {
+ delete data.html;
+ delete data.mjml;
+ }
+
+ templateTypes.html = {
+ typeName: t('HTML'),
+ getForm: owner => ,
+ afterLoad: data => {
+ console.log(data);
+ data.html = data.data.html;
+ },
+ beforeSave: (data) => {
+ data.data = {
+ html: data.html
+ };
+
+ clearBeforeSend(data);
+ },
+ };
+
+ templateTypes.mjml = {
+ typeName: t('MJML'),
+ getForm: owner => ,
+ afterLoad: data => {
+ data.mjml = data.data.mjml;
+ },
+ beforeSave: (data) => {
+ data.data = {
+ mjml: data.mjml
+ };
+
+ clearBeforeSend(data);
+ },
+ };
+
+ return templateTypes;
+}
\ No newline at end of file
diff --git a/client/src/templates/root.js b/client/src/templates/root.js
index 4554e50f..23b96670 100644
--- a/client/src/templates/root.js
+++ b/client/src/templates/root.js
@@ -6,6 +6,8 @@ import TemplatesCUD from './CUD';
import TemplatesList from './List';
import Share from '../shares/Share';
import Files from "../lib/files";
+import MosaicoCUD from './mosaico/CUD';
+import MosaicoList from './mosaico/List';
function getMenus(t) {
@@ -45,6 +47,45 @@ function getMenus(t) {
create: {
title: t('Create'),
panelRender: props =>
+ },
+ mosaico: {
+ title: t('Mosaico Templates'),
+ link: '/templates/mosaico',
+ panelComponent: MosaicoList,
+ children: {
+ ':mosaiceTemplateId([0-9]+)': {
+ title: resolved => t('Mosaico Template "{{name}}"', {name: resolved.mosaicoTemplate.name}),
+ resolve: {
+ mosaicoTemplate: params => `/rest/mosaico-templates/${params.mosaiceTemplateId}`
+ },
+ link: params => `/templates/mosaico/${params.mosaiceTemplateId}/edit`,
+ navs: {
+ ':action(edit|delete)': {
+ title: t('Edit'),
+ link: params => `/templates/mosaico/${params.mosaiceTemplateId}/edit`,
+ visible: resolved => resolved.mosaicoTemplate.permissions.includes('edit'),
+ panelRender: props =>
+ },
+ files: {
+ title: t('Files'),
+ link: params => `/templates/mosaico/${params.mosaiceTemplateId}/files`,
+ visible: resolved => resolved.mosaicoTemplate.permissions.includes('edit'),
+ panelRender: props =>
+ },
+ share: {
+ title: t('Share'),
+ link: params => `/templates/mosaico/${params.mosaiceTemplateId}/share`,
+ visible: resolved => resolved.mosaicoTemplate.permissions.includes('share'),
+ panelRender: props =>
+ }
+ }
+ },
+ create: {
+ title: t('Create'),
+ extraParams: [':wizard?'],
+ panelRender: props =>
+ }
+ }
}
}
}
diff --git a/config/default.toml b/config/default.toml
index aeaf0181..0a3f0c0c 100644
--- a/config/default.toml
+++ b/config/default.toml
@@ -204,7 +204,7 @@ rootNamespaceRole="master"
[roles.namespace.master]
name="Master"
description="All permissions"
-permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "manageUsers"]
+permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "manageUsers"]
[roles.namespace.master.children]
list=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"]
@@ -213,7 +213,8 @@ campaign=["view", "edit", "delete", "share", "manageFiles"]
template=["view", "edit", "delete", "share", "manageFiles"]
report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
reportTemplate=["view", "edit", "delete", "share", "execute"]
-namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "manageUsers"]
+mosaicoTemplate=["view", "edit", "delete", "share", "manageFiles"]
+namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "createTemplate", "createMosaicoTemplate", "manageUsers"]
[roles.list.master]
name="Master"
@@ -245,6 +246,10 @@ name="Master"
description="All permissions"
permissions=["view", "edit", "delete", "share", "execute"]
+[roles.mosaicoTemplate.master]
+name="Master"
+description="All permissions"
+permissions=["view", "edit", "delete", "share", "manageFiles"]
[roles.global.editor]
@@ -297,3 +302,7 @@ name="Editor"
description="XXX"
permissions=[]
+[roles.mosaicoTemplate.editor]
+name="Editor"
+description="All permissions"
+permissions=[]
diff --git a/lib/permissions.js b/lib/permissions.js
index 31de941f..36c78004 100644
--- a/lib/permissions.js
+++ b/lib/permissions.js
@@ -19,12 +19,14 @@ const entityTypes = {
campaign: {
entitiesTable: 'campaigns',
sharesTable: 'shares_campaign',
- permissionsTable: 'permissions_campaign'
+ permissionsTable: 'permissions_campaign',
+ filesTable: 'files_campaign'
},
template: {
entitiesTable: 'templates',
sharesTable: 'shares_template',
- permissionsTable: 'permissions_template'
+ permissionsTable: 'permissions_template',
+ filesTable: 'files_template'
},
report: {
entitiesTable: 'reports',
@@ -35,6 +37,12 @@ const entityTypes = {
entitiesTable: 'report_templates',
sharesTable: 'shares_report_template',
permissionsTable: 'permissions_report_template'
+ },
+ mosaicoTemplate: {
+ entitiesTable: 'mosaico_templates',
+ sharesTable: 'shares_mosaico_template',
+ permissionsTable: 'permissions_mosaico_template',
+ filesTable: 'files_mosaico_template'
}
};
diff --git a/models/files.js b/models/files.js
index dc356c66..933418cc 100644
--- a/models/files.js
+++ b/models/files.js
@@ -7,21 +7,26 @@ const shares = require('./shares');
const fs = require('fs-extra-promise');
const path = require('path');
const interoperableErrors = require('../shared/interoperable-errors');
+const permissions = require('../lib/permissions');
+
+const entityTypes = permissions.getEntityTypes();
const filesDir = path.join(__dirname, '..', 'files');
-const permittedTypes = new Set(['template']);
+function enforceTypePermitted(type) {
+ enforce(type in entityTypes && entityTypes[type].filesTable);
+}
function getFilePath(type, entityId, filename) {
return path.join(path.join(filesDir, type, entityId.toString()), filename);
}
function getFilesTable(type) {
- return 'files_' + type;
+ return entityTypes[type].filesTable;
}
async function listDTAjax(context, type, entityId, params) {
- enforce(permittedTypes.has(type));
+ enforceTypePermitted(type);
await shares.enforceEntityPermission(context, type, entityId, 'manageFiles');
return await dtHelpers.ajaxList(
params,
@@ -38,7 +43,7 @@ async function list(context, type, entityId) {
}
async function getFileById(context, type, id) {
- enforce(permittedTypes.has(type));
+ enforceTypePermitted(type);
const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type)).where('id', id).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, 'manageFiles');
@@ -57,7 +62,7 @@ async function getFileById(context, type, id) {
}
async function getFileByFilename(context, type, entityId, name) {
- enforce(permittedTypes.has(type));
+ enforceTypePermitted(type);
const file = await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, 'view');
const file = await tx(getFilesTable(type)).where({entity: entityId, filename: name}).first();
@@ -76,7 +81,7 @@ async function getFileByFilename(context, type, entityId, name) {
}
async function createFiles(context, type, entityId, files, dontReplace = false) {
- enforce(permittedTypes.has(type));
+ enforceTypePermitted(type);
if (files.length == 0) {
// No files uploaded
return {uploaded: 0};
diff --git a/models/mosaico-templates.js b/models/mosaico-templates.js
new file mode 100644
index 00000000..c9c04752
--- /dev/null
+++ b/models/mosaico-templates.js
@@ -0,0 +1,110 @@
+'use strict';
+
+const knex = require('../lib/knex');
+const hasher = require('node-object-hash')();
+const { enforce, filterObject } = require('../lib/helpers');
+const dtHelpers = require('../lib/dt-helpers');
+const interoperableErrors = require('../shared/interoperable-errors');
+const namespaceHelpers = require('../lib/namespace-helpers');
+const shares = require('./shares');
+
+const allowedKeys = new Set(['name', 'description', 'type', 'data', 'namespace']);
+
+function hash(entity) {
+ return hasher.hash(filterObject(entity, allowedKeys));
+}
+
+async function getById(context, id) {
+ return await knex.transaction(async tx => {
+ await shares.enforceEntityPermissionTx(tx, context, 'mosaicoTemplate', id, 'view');
+ const entity = await tx('mosaico_templates').where('id', id).first();
+ entity.data = JSON.parse(entity.data);
+ entity.permissions = await shares.getPermissionsTx(tx, context, 'mosaicoTemplate', id);
+ return entity;
+ });
+}
+
+async function listDTAjax(context, params) {
+ return await dtHelpers.ajaxListWithPermissions(
+ context,
+ [{ entityTypeId: 'mosaicoTemplate', requiredOperations: ['view'] }],
+ params,
+ builder => builder.from('mosaico_templates').innerJoin('namespaces', 'namespaces.id', 'mosaico_templates.namespace'),
+ [ 'mosaico_templates.id', 'mosaico_templates.name', 'mosaico_templates.description', 'mosaico_templates.type', 'mosaico_templates.created', 'namespaces.name' ]
+ );
+}
+
+async function _validateAndPreprocess(tx, entity) {
+ entity.data = JSON.stringify(entity.data);
+}
+
+async function create(context, entity) {
+ return await knex.transaction(async tx => {
+ await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createMosaicoTemplate');
+
+ await _validateAndPreprocess(tx, entity);
+
+ await namespaceHelpers.validateEntity(tx, entity);
+
+ const ids = await tx('mosaico_templates').insert(filterObject(entity, allowedKeys));
+ const id = ids[0];
+
+ await shares.rebuildPermissionsTx(tx, { entityTypeId: 'mosaicoTemplate', entityId: id });
+
+ return id;
+ });
+}
+
+async function updateWithConsistencyCheck(context, entity) {
+ await knex.transaction(async tx => {
+ await shares.enforceGlobalPermission(context, 'createJavascriptWithROAccess');
+ await shares.enforceEntityPermissionTx(tx, context, 'mosaicoTemplate', entity.id, 'edit');
+
+ const existing = await tx('mosaico_templates').where('id', entity.id).first();
+ if (!existing) {
+ throw new interoperableErrors.NotFoundError();
+ }
+
+ existing.data = JSON.parse(existing.data);
+
+ const existingHash = hash(existing);
+ if (existingHash !== entity.originalHash) {
+ throw new interoperableErrors.ChangedError();
+ }
+
+ await _validateAndPreprocess(tx, entity);
+
+ await namespaceHelpers.validateEntity(tx, entity);
+ await namespaceHelpers.validateMove(context, entity, existing, 'mosaicoTemplate', 'createMosaicoTemplate', 'delete');
+
+ await tx('mosaico_templates').where('id', entity.id).update(filterObject(entity, allowedKeys));
+
+ await shares.rebuildPermissionsTx(tx, { entityTypeId: 'mosaicoTemplate', entityId: entity.id });
+ });
+}
+
+async function remove(context, id) {
+ await knex.transaction(async tx => {
+ const rows = await tx('templates').where('type', 'mosaico').select(['data']);
+ for (const row of rows) {
+ const data = JSON.parse(row.data);
+ if (data.template === id) {
+ throw new interoperableErrors.DependencyPresentError();
+ }
+ }
+
+ await shares.enforceEntityPermissionTx(tx, context, 'mosaicoTemplate', id, 'delete');
+
+ await tx('mosaico_templates').where('id', id).del();
+ });
+}
+
+
+module.exports = {
+ hash,
+ getById,
+ listDTAjax,
+ create,
+ updateWithConsistencyCheck,
+ remove
+};
\ No newline at end of file
diff --git a/models/templates.js b/models/templates.js
index 016cc776..60276df5 100644
--- a/models/templates.js
+++ b/models/templates.js
@@ -36,7 +36,7 @@ async function listDTAjax(context, params) {
);
}
-async function _validateAndPreprocess(tx, entity, isCreate) {
+async function _validateAndPreprocess(tx, entity) {
entity.data = JSON.stringify(entity.data);
}
@@ -44,7 +44,7 @@ async function create(context, entity) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createTemplate');
- await _validateAndPreprocess(tx, entity, true);
+ await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateEntity(tx, entity);
@@ -73,7 +73,7 @@ async function updateWithConsistencyCheck(context, entity) {
throw new interoperableErrors.ChangedError();
}
- await _validateAndPreprocess(tx, entity, false);
+ await _validateAndPreprocess(tx, entity);
await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'template', 'createTemplate', 'delete');
diff --git a/routes/mosaico.js b/routes/mosaico.js
index 820b74a7..3f97690a 100644
--- a/routes/mosaico.js
+++ b/routes/mosaico.js
@@ -123,6 +123,7 @@ router.getAsync('/img/:type/:fileId', passport.loggedIn, async (req, res) => {
} else {
width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, true);
+ // TODO - validate that one has the rights to read this ???
image = await resizedImage(req.query.src, method, width, height);
}
diff --git a/routes/rest/mosaico-templates.js b/routes/rest/mosaico-templates.js
new file mode 100644
index 00000000..c955b864
--- /dev/null
+++ b/routes/rest/mosaico-templates.js
@@ -0,0 +1,36 @@
+'use strict';
+
+const passport = require('../../lib/passport');
+const mosaicoTemplates = require('../../models/mosaico-templates');
+
+const router = require('../../lib/router-async').create();
+
+
+router.getAsync('/mosaico-templates/:mosaicoTemplateId', passport.loggedIn, async (req, res) => {
+ const mosaicoTemplate = await mosaicoTemplates.getById(req.context, req.params.mosaicoTemplateId);
+ mosaicoTemplate.hash = mosaicoTemplates.hash(mosaicoTemplate);
+ return res.json(mosaicoTemplate);
+});
+
+router.postAsync('/mosaico-templates', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ return res.json(await mosaicoTemplates.create(req.context, req.body));
+});
+
+router.putAsync('/mosaico-templates/:mosaicoTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ const mosaicoTemplate = req.body;
+ mosaicoTemplate.id = parseInt(req.params.mosaicoTemplateId);
+
+ await mosaicoTemplates.updateWithConsistencyCheck(req.context, mosaicoTemplate);
+ return res.json();
+});
+
+router.deleteAsync('/mosaico-templates/:mosaicoTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ await mosaicoTemplates.remove(req.context, req.params.mosaicoTemplateId);
+ return res.json();
+});
+
+router.postAsync('/mosaico-templates-table', passport.loggedIn, async (req, res) => {
+ return res.json(await mosaicoTemplates.listDTAjax(req.context, req.body));
+});
+
+module.exports = router;
\ No newline at end of file
diff --git a/setup/knex/migrations/20170731072050_upgrade_custom_fields.js b/setup/knex/migrations/20170731072050_upgrade_custom_fields.js
index 87b3e58f..86d9c977 100644
--- a/setup/knex/migrations/20170731072050_upgrade_custom_fields.js
+++ b/setup/knex/migrations/20170731072050_upgrade_custom_fields.js
@@ -2,7 +2,7 @@
exports.up = (knex, Promise) => (async() => {
await knex.schema.table('custom_fields', table => {
- table.json('settings');
+ table.text('settings');
});
await knex.schema.table('custom_fields', table => {
diff --git a/setup/knex/migrations/20170814174051_upgrade_segments.js b/setup/knex/migrations/20170814174051_upgrade_segments.js
index f94e1544..47676e35 100644
--- a/setup/knex/migrations/20170814174051_upgrade_segments.js
+++ b/setup/knex/migrations/20170814174051_upgrade_segments.js
@@ -2,7 +2,7 @@
exports.up = (knex, Promise) => (async() => {
await knex.schema.table('segments', table => {
- table.json('settings');
+ table.text('settings');
});
await knex.schema.table('segments', table => {
diff --git a/setup/knex/migrations/20180110120444_add_files.js b/setup/knex/migrations/20180110120444_add_files.js
index c4f3d6d9..3c5bebc5 100644
--- a/setup/knex/migrations/20180110120444_add_files.js
+++ b/setup/knex/migrations/20180110120444_add_files.js
@@ -5,7 +5,7 @@ exports.up = (knex, Promise) => (async() => {
await knex.schema.createTable(`files_${entityType}`, table => {
table.increments('id').primary();
- table.integer('entity').unsigned().notNullable().references('templates.id');
+ table.integer('entity').unsigned().notNullable().references(`${entityType}s.id`);
table.string('filename');
table.string('originalname');
table.string('mimetype');
diff --git a/setup/knex/migrations/20180111120444_upgrade_templates.js b/setup/knex/migrations/20180111120444_upgrade_templates.js
index 5123fc5d..c57ea8d6 100644
--- a/setup/knex/migrations/20180111120444_upgrade_templates.js
+++ b/setup/knex/migrations/20180111120444_upgrade_templates.js
@@ -1,6 +1,6 @@
exports.up = (knex, Promise) => (async() => {
await knex.schema.table('templates', table => {
- table.json('data');
+ table.text('data', 'longtext');
table.string('type');
});
diff --git a/setup/knex/migrations/20180401120444_create_mosaico_templates.js b/setup/knex/migrations/20180401120444_create_mosaico_templates.js
new file mode 100644
index 00000000..de8bf347
--- /dev/null
+++ b/setup/knex/migrations/20180401120444_create_mosaico_templates.js
@@ -0,0 +1,61 @@
+const mosaicoTemplates = require('../../../shared/mosaico-templates');
+
+exports.up = (knex, Promise) => (async() => {
+ await knex.schema.createTable('mosaico_templates', table => {
+ table.increments('id').primary();
+ table.string('name');
+ table.text('description');
+ table.string('type');
+ table.text('data', 'longtext');
+ table.timestamp('created').defaultTo(knex.fn.now());
+ table.integer('namespace').unsigned().references('namespaces.id');
+ });
+
+ await knex.schema.createTable(`shares_mosaico_template`, table => {
+ table.integer('entity').unsigned().notNullable().references(`mosaico_templates.id`).onDelete('CASCADE');
+ table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
+ table.string('role', 128).notNullable();
+ table.boolean('auto').defaultTo(false);
+ table.primary(['entity', 'user']);
+ });
+
+ await knex.schema.createTable(`permissions_mosaico_template`, table => {
+ table.integer('entity').unsigned().notNullable().references(`mosaico_templates.id`).onDelete('CASCADE');
+ table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
+ table.string('operation', 128).notNullable();
+ table.primary(['entity', 'user', 'operation']);
+ });
+
+ await knex.schema.createTable(`files_mosaico_template`, table => {
+ table.increments('id').primary();
+ table.integer('entity').unsigned().notNullable().references('mosaico_templates.id');
+ table.string('filename');
+ table.string('originalname');
+ table.string('mimetype');
+ table.string('encoding');
+ table.integer('size');
+ table.timestamp('created').defaultTo(knex.fn.now());
+ table.index(['entity', 'originalname'])
+ });
+
+ const versafix = {
+ name: 'Versafix One',
+ description: 'Default Mosaico Template',
+ type: 'html',
+ namespace: 1,
+ data: JSON.stringify({
+ html: mosaicoTemplates.versafix
+ })
+ };
+
+ await knex('mosaico_templates').insert(versafix);
+})();
+
+exports.down = (knex, Promise) => (async() => {
+ await knex.schema
+ .dropTable('shares_mosaico_template')
+ .dropTable('permissions_mosaico_template')
+ .dropTable('files_mosaico_template')
+ .dropTable('mosaico_templates')
+ ;
+})();
diff --git a/shared/interoperable-errors.js b/shared/interoperable-errors.js
index 34a37497..ad5a6a80 100644
--- a/shared/interoperable-errors.js
+++ b/shared/interoperable-errors.js
@@ -106,6 +106,13 @@ class InvalidConfirmationForUnsubscriptionError extends InteroperableError {
}
}
+class DependencyPresentError extends InteroperableError {
+ constructor(msg, data) {
+ super('DependencyPresentError', msg, data);
+ }
+}
+
+
const errorTypes = {
InteroperableError,
NotLoggedInError,
@@ -123,7 +130,8 @@ const errorTypes = {
PermissionDeniedError,
InvalidConfirmationForSubscriptionError,
InvalidConfirmationForAddressChangeError,
- InvalidConfirmationForUnsubscriptionError
+ InvalidConfirmationForUnsubscriptionError,
+ DependencyPresentError
};
function deserialize(errorObj) {
diff --git a/shared/mosaico-templates.js b/shared/mosaico-templates.js
new file mode 100644
index 00000000..6260612b
--- /dev/null
+++ b/shared/mosaico-templates.js
@@ -0,0 +1,1537 @@
+'use strict';
+
+const versafix = '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' TITLE\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Preferences\n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' View in your browser\n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ '\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '  \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Title\n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia. \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' BUTTON\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Section Title\n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia. \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' BUTTON\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Section Title\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. \n' +
+ ' Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia. \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Title\n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' BUTTON\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Title\n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' BUTTON\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Title\n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' BUTTON\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Title\n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' BUTTON\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Title\n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' BUTTON\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' BUTTON\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '  \n' +
+ ' \n' +
+ '  \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '  \n' +
+ ' \n' +
+ '  \n' +
+ ' \n' +
+ '  \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Address and Contacts \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ '
\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Email sent to [EMAIL] | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Unsubscribe\n' +
+ ' | \n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' | \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '\n' +
+ ' | \n' +
+ '
\n' +
+ '
\n' +
+ ' \n' +
+ '\n' +
+ ' \n' +
+ '\n' +
+ '\n';
+
+module.exports = {
+ versafix
+};
\ No newline at end of file