Send test functionality for templates and campaigns

This commit is contained in:
Tomas Bures 2018-11-14 22:29:31 +01:00
parent 7e52000219
commit 2c73c536b7
22 changed files with 719 additions and 69 deletions

View file

@ -27,6 +27,7 @@ import {
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
import {TestSendModalDialog} from "./TestSendModalDialog";
@translate()
@ -49,10 +50,13 @@ export default class CustomContent extends Component {
this.state = {
showMergeTagReference: false,
elementInFullscreen: false
elementInFullscreen: false,
showTestSendModal: false
};
this.initForm();
this.sendModalGetDataHandler = ::this.sendModalGetData;
}
static propTypes = {
@ -84,7 +88,7 @@ export default class CustomContent extends Component {
const t = this.props.t;
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
const exportedData = await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
const sendMethod = FormSendMethod.PUT;
const url = `rest/campaigns-content/${this.props.entity.id}`;
@ -93,6 +97,7 @@ export default class CustomContent extends Component {
this.setFormStatusMessage('info', t('Saving ...'));
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
Object.assign(data, exportedData);
this.templateTypes[data.data_sourceCustom_type].beforeSave(data);
data.data.sourceCustom = {
@ -123,9 +128,10 @@ export default class CustomContent extends Component {
async extractPlainText() {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
const exportedData = await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
const html = exportedData.data_sourceCustom_html;
const html = this.getFormValue('data_sourceCustom_html');
if (!html) {
return;
}
@ -155,6 +161,22 @@ export default class CustomContent extends Component {
});
}
showTestSendModal() {
this.setState({
showTestSendModal: true
});
}
async sendModalGetData() {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
const exportedData = await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
return {
html: exportedData.data_sourceCustom_html,
text: this.getFormValue('data_sourceCustom_text')
};
}
render() {
const t = this.props.t;
@ -166,6 +188,13 @@ export default class CustomContent extends Component {
return (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
<TestSendModalDialog
visible={this.state.showTestSendModal}
onHide={() => this.setState({showTestSendModal: false})}
getDataAsync={this.sendModalGetDataHandler}
entity={this.props.entity}
/>
<Title>{t('Edit Custom Content')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>

View file

@ -0,0 +1,124 @@
'use strict';
import React, {Component} from 'react';
import {translate} from 'react-i18next';
import PropTypes
from 'prop-types';
import {ModalDialog} from "../lib/bootstrap-components";
import {
requiresAuthenticatedUser,
withPageHelpers
} from "../lib/page";
import {
Form,
TableSelect,
withForm
} from "../lib/form";
import {withErrorHandling} from "../lib/error-handling";
import {getMailerTypes} from "../send-configurations/helpers";
import axios from '../lib/axios';
import {} from '../lib/urls';
import {getUrl} from "../lib/urls";
@translate()
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export class TestSendModalDialog extends Component {
constructor(props) {
super(props);
this.mailerTypes = getMailerTypes(props.t);
this.initForm();
}
static propTypes = {
stateOwner: PropTypes.object,
visible: PropTypes.bool.isRequired,
onHide: PropTypes.func.isRequired,
getDataAsync: PropTypes.func.isRequired,
entity: PropTypes.object
}
componentDidMount() {
this.populateFormValues({
testUser: null,
});
}
async hideModal() {
this.props.onHide();
}
async performAction() {
const props = this.props;
const t = props.t;
if (this.isFormWithoutErrors()) {
try {
this.hideFormValidation();
this.disableForm();
this.setFormStatusMessage('info', t('Sending test email'));
const data = await this.props.getDataAsync();
const campaignCid = props.entity.cid;
const [listCid, subscriptionCid] = this.getFormValue('testUser').split(':');
data.listCid = listCid;
data.subscriptionCid = subscriptionCid;
data.sendConfigurationId = props.entity.send_configuration;
data.campaignId = props.entity.id;
console.log(await axios.post(getUrl('rest/template-test-send'), data));
this.clearFormStatusMessage();
this.enableForm();
await this.hideModal();
} catch (err) {
throw err;
}
} else {
this.showFormValidation();
}
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['testUser', 'value'])) {
state.setIn(['testUser', 'error'], t('Subscription has to be selected.'))
} else {
state.setIn(['testUser', 'error'], null);
}
}
render() {
const t = this.props.t;
const testUsersColumns = [
{ data: 1, title: t('Email') },
{ data: 2, title: t('Subscription ID'), render: data => <code>{data}</code> },
{ data: 3, title: t('List ID'), render: data => <code>{data}</code> },
{ data: 4, title: t('List') },
{ data: 5, title: t('List namespace') }
];
return (
<ModalDialog hidden={!this.props.visible} title={t('Send Test Email')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('Send'), className: 'btn-danger', onClickAsync: ::this.performAction },
{ label: t('Cancel'), className: 'btn-primary', onClickAsync: ::this.hideModal }
]}>
<Form stateOwner={this} format="wide">
<TableSelect id="testUser" format="wide" label={t('Subscription')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.entity.id}`} columns={testUsersColumns} selectionLabelIndex={1} />
</Form>
</ModalDialog>
);
}
}

View file

@ -210,7 +210,8 @@ class ModalDialog extends Component {
onCloseAsync: PropTypes.func,
onButtonClickAsync: PropTypes.func,
buttons: PropTypes.array,
hidden: PropTypes.bool
hidden: PropTypes.bool,
className: PropTypes.string
}
/*
@ -281,7 +282,11 @@ class ModalDialog extends Component {
}
return (
<div ref={(domElem) => { this.domModal = domElem; }} className="modal fade" tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
<div
ref={(domElem) => { this.domModal = domElem; }}
className={'modal fade' + (props.className ? ' ' + props.className : '')}
tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">

View file

@ -43,25 +43,32 @@ class CKEditorSandbox extends Component {
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const html = this.props.initialHtml && base(this.props.initialHtml, trustedUrlBase, sandboxUrlBase, publicUrlBase);
const source = this.props.initialSource && base(this.props.initialSource, trustedUrlBase, sandboxUrlBase, publicUrlBase);
this.state = {
html
source
};
}
static propTypes = {
entityTypeId: PropTypes.string,
entityId: PropTypes.number,
initialHtml: PropTypes.string
initialSource: PropTypes.string
}
async exportState(method, params) {
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const preHtml = '<!doctype html><html><head><meta charset="utf-8"><title></title></head><body>';
const postHtml = '</body></html>';
const unbasedSource = unbase(this.state.source, trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
return {
html: unbase(this.state.html, trustedUrlBase, sandboxUrlBase, publicUrlBase, true)
source: unbasedSource,
html: preHtml + unbasedSource + postHtml
};
}
@ -119,9 +126,9 @@ class CKEditorSandbox extends Component {
return (
<div className={styles.sandbox}>
<CKEditor ref={node => this.node = node}
content={this.state.html}
content={this.state.source}
events={{
change: evt => this.setState({html: evt.editor.getData()}),
change: evt => this.setState({source: evt.editor.getData()}),
}}
config={config}
/>

View file

@ -29,8 +29,9 @@ export class CKEditorHost extends Component {
static propTypes = {
entityTypeId: PropTypes.string,
entity: PropTypes.object,
initialHtml: PropTypes.string,
initialSource: PropTypes.string,
title: PropTypes.string,
onTestSend: PropTypes.func,
onFullscreenAsync: PropTypes.func
}
@ -75,7 +76,7 @@ export class CKEditorHost extends Component {
const editorData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id,
initialHtml: this.props.initialHtml
initialSource: this.props.initialSource
};
const tokenData = {
@ -89,6 +90,7 @@ export class CKEditorHost extends Component {
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync}><Icon icon="fullscreen"/></a>
<a className={styles.btn} onClick={this.props.onTestSend}><Icon icon="send"/></a>
</div>
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="ckeditor/editor" tokenMethod="ckeditor" tokenParams={editorData}/>
</div>

View file

@ -39,6 +39,8 @@ import {CodeEditorSourceType} from "./sandboxed-codeeditor-shared";
import mjml2html from "mjml4-in-browser";
import juice from "juice";
const refreshTimeout = 1000;
@translate(null, { withRef: true })
class CodeEditorSandbox extends Component {
constructor(props) {
@ -85,6 +87,12 @@ class CodeEditorSandbox extends Component {
source,
preview: props.initialPreview
};
this.state.previewContents = this.getHtml();
this.onCodeChangedHandler = ::this.onCodeChanged;
this.refreshHandler = ::this.refresh;
this.refreshTimeoutId = null;
}
static propTypes = {
@ -100,6 +108,7 @@ class CodeEditorSandbox extends Component {
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
return {
html: unbase(this.getHtml(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
source: unbase(this.state.source, trustedUrlBase, sandboxUrlBase, publicUrlBase, true)
};
}
@ -115,9 +124,12 @@ class CodeEditorSandbox extends Component {
parentRPC.setMethodHandler('setPreview', ::this.setPreview);
}
render() {
let previewContents;
componentWillUnmount() {
clearTimeout(this.refreshTimeoutId);
}
getHtml() {
let previewContents;
if (this.props.sourceType === CodeEditorSourceType.MJML) {
const res = mjml2html(this.state.source);
previewContents = res.html;
@ -125,6 +137,28 @@ class CodeEditorSandbox extends Component {
previewContents = juice(this.state.source);
}
return previewContents;
}
onCodeChanged(data) {
this.setState({
source: data
});
if (!this.refreshTimeoutId) {
this.refreshTimeoutId = setTimeout(() => this.refresh(), refreshTimeout);
}
}
refresh() {
this.refreshTimeoutId = null;
this.setState({
previewContents: this.getHtml()
});
}
render() {
return (
<div className={styles.sandbox}>
<div className={this.state.preview ? styles.aceEditorWithPreview : styles.aceEditorWithoutPreview}>
@ -133,7 +167,7 @@ class CodeEditorSandbox extends Component {
theme="github"
width="100%"
height="100%"
onChange={data => this.setState({source: data})}
onChange={this.onCodeChangedHandler}
fontSize={12}
showPrintMargin={false}
value={this.state.source}
@ -144,7 +178,7 @@ class CodeEditorSandbox extends Component {
{
this.state.preview &&
<div className={styles.preview}>
<iframe src={"data:text/html;charset=utf-8," + escape(previewContents)}></iframe>
<iframe src={"data:text/html;charset=utf-8," + escape(this.state.previewContents)}></iframe>
</div>
}
</div>

View file

@ -28,6 +28,7 @@ export class CodeEditorHost extends Component {
initialSource: PropTypes.string,
sourceType: PropTypes.string,
title: PropTypes.string,
onTestSend: PropTypes.func,
onFullscreenAsync: PropTypes.func
}
@ -47,6 +48,7 @@ export class CodeEditorHost extends Component {
await this.contentNode.ask('setPreview', preview);
}
async exportState() {
return await this.contentNode.ask('exportState');
}
@ -73,6 +75,7 @@ export class CodeEditorHost extends Component {
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync}><Icon icon="fullscreen"/></a>
<a className={styles.btn} onClick={this.props.onTestSend}><Icon icon="send"/></a>
<a className={styles.btn} onClick={::this.togglePreviewAsync}><Icon icon={this.state.preview ? 'eye-close': 'eye-open'}/></a>
</div>
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="codeeditor/editor" tokenMethod="codeeditor" tokenParams={tokenData}/>

View file

@ -107,7 +107,11 @@ export class GrapesJSSandbox extends Component {
const commandManager = editor.Commands;
const cmdGetCode = commandManager.get('gjs-get-inlined-html');
html = cmdGetCode.run(editor);
const htmlBody = cmdGetCode.run(editor);
const preHtml = '<!doctype html><html><head><meta charset="utf-8"><title></title></head><body>';
const postHtml = '</body></html>';
html = preHtml + unbase(htmlBody, trustedUrlBase, sandboxUrlBase, publicUrlBase, true) + postHtml;
}

View file

@ -28,6 +28,7 @@ export class GrapesJSHost extends Component {
initialStyle: PropTypes.string,
sourceType: PropTypes.string,
title: PropTypes.string,
onTestSend: PropTypes.func,
onFullscreenAsync: PropTypes.func
}
@ -65,6 +66,7 @@ export class GrapesJSHost extends Component {
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync}><Icon icon="fullscreen"/></a>
<a className={styles.btn} onClick={this.props.onTestSend}><Icon icon="send"/></a>
</div>
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="grapesjs/editor" tokenMethod="grapesjs" tokenParams={tokenData}/>
</div>

View file

@ -26,6 +26,7 @@ export class MosaicoHost extends Component {
entityTypeId: PropTypes.string,
entity: PropTypes.object,
title: PropTypes.string,
onTestSend: PropTypes.func,
onFullscreenAsync: PropTypes.func,
templateId: PropTypes.number,
templatePath: PropTypes.string,
@ -68,6 +69,7 @@ export class MosaicoHost extends Component {
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync}><Icon icon="fullscreen"/></a>
<a className={styles.btn} onClick={this.props.onTestSend}><Icon icon="send"/></a>
</div>
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={tokenData}/>
</div>

View file

@ -146,3 +146,15 @@
.dependenciesList {
margin-bottom: 0px;
}
:global .modal-dialog {
@media (min-width: 768px) {
width: 700px;
}
@media (min-width: 1000px) {
width: 900px;
}
}

View file

@ -35,6 +35,7 @@ import {
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
import {TestSendModalDialog} from "./TestSendModalDialog";
@translate()
@ -50,7 +51,8 @@ export default class CUD extends Component {
this.state = {
showMergeTagReference: false,
elementInFullscreen: false
elementInFullscreen: false,
showTestSendModal: false
};
this.initForm({
@ -58,6 +60,8 @@ export default class CUD extends Component {
type: ::this.onTypeChanged
}
});
this.sendModalGetDataHandler = ::this.sendModalGetData;
}
static propTypes = {
@ -117,9 +121,10 @@ export default class CUD extends Component {
async submitHandler() {
const t = this.props.t;
let exportedData = {};
if (this.props.entity) {
const typeKey = this.getFormValue('type');
await this.templateTypes[typeKey].exportHTMLEditorData(this);
exportedData = await this.templateTypes[typeKey].exportHTMLEditorData(this);
}
let sendMethod, url;
@ -135,6 +140,7 @@ export default class CUD extends Component {
this.setFormStatusMessage('info', t('Saving ...'));
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
Object.assign(data, exportedData);
this.templateTypes[data.type].beforeSave(data);
});
@ -152,9 +158,9 @@ export default class CUD extends Component {
async extractPlainText() {
const typeKey = this.getFormValue('type');
await this.templateTypes[typeKey].exportHTMLEditorData(this);
const exportedData = await this.templateTypes[typeKey].exportHTMLEditorData(this);
const html = this.getFormValue('html');
const html = exportedData.html;
if (!html) {
return;
}
@ -184,6 +190,22 @@ export default class CUD extends Component {
});
}
showTestSendModal() {
this.setState({
showTestSendModal: true
});
}
async sendModalGetData() {
const typeKey = this.getFormValue('type');
const exportedData = await this.templateTypes[typeKey].exportHTMLEditorData(this);
return {
html: exportedData.html,
text: this.getFormValue('text')
};
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
@ -194,8 +216,6 @@ export default class CUD extends Component {
typeOptions.push({key, label: this.templateTypes[key].typeName});
}
// TODO: Toggle HTML preview
const typeKey = this.getFormValue('type');
let editForm = null;
@ -211,6 +231,12 @@ export default class CUD extends Component {
return (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
{isEdit &&
<TestSendModalDialog
visible={this.state.showTestSendModal}
onHide={() => this.setState({showTestSendModal: false})}
getDataAsync={this.sendModalGetDataHandler}/>
}
{canDelete &&
<DeleteModalDialog
stateOwner={this}
@ -246,6 +272,7 @@ export default class CUD extends Component {
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={isEdit ? t('Save') : t('Save and edit template')}/>
{canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/templates/${this.props.entity.id}/delete`}/> }
{isEdit && <Button className="btn-danger" icon="send" label={t('Test send')} onClickAsync={async () => this.setState({showTestSendModal: true})}/> }
</ButtonRow>
</Form>
</div>

View file

@ -0,0 +1,153 @@
'use strict';
import React, {Component} from 'react';
import {translate} from 'react-i18next';
import PropTypes
from 'prop-types';
import {ModalDialog} from "../lib/bootstrap-components";
import {
requiresAuthenticatedUser,
withPageHelpers
} from "../lib/page";
import {
Form,
TableSelect,
withForm
} from "../lib/form";
import {withErrorHandling} from "../lib/error-handling";
import moment
from "moment";
import {getMailerTypes} from "../send-configurations/helpers";
import axios from '../lib/axios';
import {getUrl} from "../lib/urls";
@translate()
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export class TestSendModalDialog extends Component {
constructor(props) {
super(props);
this.mailerTypes = getMailerTypes(props.t);
this.initForm();
}
static propTypes = {
stateOwner: PropTypes.object,
visible: PropTypes.bool.isRequired,
onHide: PropTypes.func.isRequired,
getDataAsync: PropTypes.func.isRequired
}
componentDidMount() {
this.populateFormValues({
list: null,
testUser: null,
sendConfiguration: null
});
}
async hideModal() {
this.props.onHide();
}
async performAction() {
const props = this.props;
const t = props.t;
if (this.isFormWithoutErrors()) {
try {
this.hideFormValidation();
this.disableForm();
this.setFormStatusMessage('info', t('Sending test email'));
const data = await this.props.getDataAsync();
data.listCid = this.getFormValue('list');
data.subscriptionCid = this.getFormValue('testUser');
data.sendConfigurationId = this.getFormValue('sendConfiguration');
console.log(await axios.post(getUrl('rest/template-test-send'), data));
this.clearFormStatusMessage();
this.enableForm();
await this.hideModal();
} catch (err) {
throw err;
}
} else {
this.showFormValidation();
}
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['sendConfiguration', 'value'])) {
state.setIn(['sendConfiguration', 'error'], t('Send configuration has to be selected.'))
} else {
state.setIn(['sendConfiguration', 'error'], null);
}
if (!state.getIn(['list', 'value'])) {
state.setIn(['list', 'error'], t('List has to be selected.'))
} else {
state.setIn(['list', 'error'], null);
}
if (!state.getIn(['testUser', 'value'])) {
state.setIn(['testUser', 'error'], t('Subscription has to be selected.'))
} else {
state.setIn(['testUser', 'error'], null);
}
}
render() {
const t = this.props.t;
const listId = this.getFormValue('list');
const testUsersColumns = [
{ data: 1, title: t('Subscription ID'), render: data => <code>{data}</code> },
{ data: 2, title: t('Email') }
];
const listsColumns = [
{ data: 1, title: t('Name') },
{ data: 2, title: t('ID'), render: data => <code>{data}</code> },
{ data: 3, title: t('Subscribers') },
{ data: 4, title: t('Description') },
{ data: 5, title: t('Namespace') }
];
const sendConfigurationsColumns = [
{ data: 1, title: t('Name') },
{ data: 2, title: t('ID'), render: data => <code>{data}</code> },
{ data: 3, title: t('Description') },
{ data: 4, title: t('Type'), render: data => this.mailerTypes[data].typeName },
{ data: 5, title: t('Created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('Namespace') }
];
return (
<ModalDialog hidden={!this.props.visible} title={t('Send Test Email')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('Send'), className: 'btn-danger', onClickAsync: ::this.performAction },
{ label: t('Cancel'), className: 'btn-primary', onClickAsync: ::this.hideModal }
]}>
<Form stateOwner={this} format="wide">
<TableSelect id="sendConfiguration" format="wide" label={t('Send configuration')} withHeader dropdown dataUrl='rest/send-configurations-with-send-permission-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
<TableSelect id="list" format="wide" label={t('List')} withHeader dropdown dataUrl={`rest/lists-table`} columns={listsColumns} selectionKeyIndex={2} selectionLabelIndex={1} />
{ listId &&
<TableSelect id="testUser" format="wide" label={t('Subscription')} withHeader dropdown dataUrl={`rest/subscriptions-test-user-table/${listId}`} columns={testUsersColumns} selectionKeyIndex={1} selectionLabelIndex={2} />
}
</Form>
</ModalDialog>
);
}
}

View file

@ -29,7 +29,11 @@ import {
} from "../lib/sandboxed-codeeditor-shared";
import {getTemplateTypes as getMosaicoTemplateTypes} from './mosaico/helpers';
import {getSandboxUrl} from "../lib/urls";
import {
getPublicUrl,
getSandboxUrl,
getTrustedUrl
} from "../lib/urls";
import mailtrainConfig from 'mailtrainConfig';
import {
ActionLink,
@ -38,6 +42,10 @@ import {
import {Trans} from "react-i18next";
import styles from "../lib/styles.scss";
import {
base,
unbase
} from "../../../shared/templates";
export const ResourceType = {
TEMPLATE: 'template',
@ -90,15 +98,19 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
templateId={owner.getFormValue(prefix + 'mosaicoTemplate')}
entityTypeId={entityTypeId}
title={t('Mosaico Template Designer')}
onFullscreenAsync={::owner.setElementInFullscreen}/>
onTestSend={::owner.showTestSendModal}
onFullscreenAsync={::owner.setElementInFullscreen}
/>
</AlignedRow>,
exportHTMLEditorData: async owner => {
const {html, metadata, model} = await owner.editorNode.exportState();
owner.updateFormValue(prefix + 'html', html);
owner.updateFormValue(prefix + 'mosaicoData', {
return {
[prefix + 'html']: html,
[prefix + 'mosaicoData']: {
metadata,
model
});
}
};
},
initData: () => ({
[prefix + 'mosaicoTemplate']: '',
@ -152,15 +164,19 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
templatePath={getSandboxUrl(`static/mosaico/templates/${owner.getFormValue(prefix + 'mosaicoFsTemplate')}/index.html`)}
entityTypeId={entityTypeId}
title={t('Mosaico Template Designer')}
onFullscreenAsync={::owner.setElementInFullscreen}/>
onTestSend={::owner.showTestSendModal}
onFullscreenAsync={::owner.setElementInFullscreen}
/>
</AlignedRow>,
exportHTMLEditorData: async owner => {
const {html, metadata, model} = await owner.editorNode.exportState();
owner.updateFormValue(prefix + 'html', html);
owner.updateFormValue(prefix + 'mosaicoData', {
return {
[prefix + 'html']: html,
[prefix + 'mosaicoData']: {
metadata,
model
});
}
};
},
initData: () => ({
[prefix + 'mosaicoFsTemplate']: mailtrainConfig.mosaico.fsTemplates[0].key,
@ -213,16 +229,19 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
initialStyle={owner.getFormValue(prefix + 'grapesJSData').style}
sourceType={owner.getFormValue(prefix + 'grapesJSSourceType')}
title={t('GrapesJS Template Designer')}
onTestSend={::owner.showTestSendModal}
onFullscreenAsync={::owner.setElementInFullscreen}
/>
</AlignedRow>,
exportHTMLEditorData: async owner => {
const {html, source, style} = await owner.editorNode.exportState();
owner.updateFormValue(prefix + 'html', html);
owner.updateFormValue(prefix + 'grapesJSData', {
return {
[prefix + 'html']: html,
[prefix + 'grapesJSData']: {
source,
style
});
}
};
},
initData: () => ({
[prefix + 'grapesJSSourceType']: GrapesJSSourceType.MJML,
@ -257,36 +276,82 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
<CKEditorHost
ref={node => owner.editorNode = node}
entity={owner.props.entity}
initialHtml={owner.getFormValue(prefix + 'html')}
initialSource={owner.getFormValue(prefix + 'ckeditor4Data').source}
entityTypeId={entityTypeId}
title={t('CKEditor 4 Template Designer')}
onTestSend={::owner.showTestSendModal}
onFullscreenAsync={::owner.setElementInFullscreen}
/>
</AlignedRow>,
exportHTMLEditorData: async owner => {
const {html} = await owner.editorNode.exportState();
owner.updateFormValue(prefix + 'html', html);
const {html, source} = await owner.editorNode.exportState();
return {
[prefix + 'html']: html,
[prefix + 'ckeditor4Data']: {
source
}
};
},
initData: () => ({
[prefix + 'ckeditor4Data']: {}
}),
afterLoad: data => {
data[prefix + 'ckeditor4Data'] = {
source: data[prefix + 'data'].source
};
},
initData: () => ({}),
afterLoad: data => {},
beforeSave: data => {
data[prefix + 'data'] = {
source: data[prefix + 'ckeditor4Data'].source,
};
clearBeforeSave(data);
},
afterTypeChange: mutState => {},
afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'ckeditor4');
},
validate: state => {}
};
templateTypes.ckeditor5 = {
typeName: t('CKEditor 5'),
getTypeForm: (owner, isEdit) => null,
getHTMLEditor: owner => <CKEditor id={prefix + 'html'} height="600px" mode="html" label={t('Template content (HTML)')}/>,
exportHTMLEditorData: async owner => {},
initData: () => ({}),
afterLoad: data => {},
getHTMLEditor: owner => <CKEditor id={prefix + 'ckeditor5Source'} height="600px" mode="html" label={t('Template content (HTML)')}/>,
exportHTMLEditorData: async owner => {
const preHtml = '<!doctype html><html><head><meta charset="utf-8"><title></title></head><body>';
const postHtml = '</body></html>';
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const unbasedSource = unbase(owner.getFormValue(prefix + 'ckeditor5Source'), trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
const html = preHtml + unbasedSource + postHtml
return {
[prefix + 'ckeditor5Source']: unbasedSource,
[prefix + 'html']: html
}
},
initData: () => ({
[prefix + 'ckeditor5Source']: ''
}),
afterLoad: data => {
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const source = base(data[prefix + 'data'].source, trustedUrlBase, sandboxUrlBase, publicUrlBase);
data[prefix + 'ckeditor5Source'] = source;
},
beforeSave: data => {
data[prefix + 'data'] = {
source: data[prefix + 'ckeditor5Source'],
};
clearBeforeSave(data);
},
afterTypeChange: mutState => {},
afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'ckeditor5');
},
validate: state => {}
};
@ -315,15 +380,18 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
initialSource={owner.getFormValue(prefix + 'codeEditorData').source}
sourceType={owner.getFormValue(prefix + 'codeEditorSourceType')}
title={t('Code Editor Template Designer')}
onTestSend={::owner.showTestSendModal}
onFullscreenAsync={::owner.setElementInFullscreen}
/>
</AlignedRow>,
exportHTMLEditorData: async owner => {
const {html, source} = await owner.editorNode.exportState();
owner.updateFormValue(prefix + 'html', html);
owner.updateFormValue(prefix + 'codeEditorData', {
return {
[prefix + 'html']: html,
[prefix + 'codeEditorData']: {
source
});
}
};
},
initData: () => ({
[prefix + 'codeEditorSourceType']: CodeEditorSourceType.HTML,

View file

@ -26,6 +26,126 @@ class CampaignSender {
constructor() {
}
static async testSend(context, listCid, subscriptionCid, campaignId, sendConfigurationId, html, text) {
let sendConfiguration, list, fieldsGrouped, campaign, subscriptionGrouped, useVerp, useVerpSenderHeader, mergeTags, attachments;
await knex.transaction(async tx => {
sendConfiguration = await sendConfigurations.getByIdTx(tx, context, sendConfigurationId, false, true);
list = await lists.getByCidTx(tx, context, listCid);
fieldsGrouped = await fields.listGroupedTx(tx, list.id);
useVerp = config.verp.enabled && sendConfiguration.verp_hostname;
useVerpSenderHeader = this.useVerp && config.verp.disablesenderheader !== true;
subscriptionGrouped = await subscriptions.getByCid(context, list.id, subscriptionCid);
mergeTags = fields.getMergeTags(fieldsGrouped, subscriptionGrouped);
if (campaignId) {
campaign = await campaigns.getByIdTx(tx, context, campaignId, false, campaigns.Content.WITHOUT_SOURCE_CUSTOM);
} else {
// This is to fake the campaign for getMessageLinks, which is called inside formatMessage
campaign = {
cid: '[CAMPAIGN_ID]'
};
}
});
const encryptionKeys = [];
for (const fld of fieldsGrouped) {
if (fld.type === 'gpg' && mergeTags[fld.key]) {
encryptionKeys.push(mergeTags[fld.key].trim());
}
}
attachments = [];
// replace data: images with embedded attachments
html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
const cid = shortid.generate() + '-attachments';
attachments.push({
path: dataUri,
cid
});
return prefix + 'cid:' + cid;
});
html = tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, html, false, true);
text = (text || '').trim()
? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, text)
: htmlToText.fromString(html, {wordwrap: 130});
const mailer = await mailers.getOrCreateMailer(sendConfiguration.id);
const getOverridable = key => {
return sendConfiguration[key];
}
const campaignAddress = [campaign.cid, list.cid, subscriptionGrouped.cid].join('.');
const mail = {
from: {
name: getOverridable('from_name'),
address: getOverridable('from_email')
},
replyTo: getOverridable('reply_to'),
xMailer: sendConfiguration.x_mailer ? sendConfiguration.x_mailer : false,
to: {
name: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false, false),
address: subscriptionGrouped.email
},
sender: this.useVerpSenderHeader ? campaignAddress + '@' + sendConfiguration.verp_hostname : false,
envelope: this.useVerp ? {
from: campaignAddress + '@' + sendConfiguration.verp_hostname,
to: subscriptionGrouped.email
} : false,
headers: {
'x-fbl': campaignAddress,
// custom header for SparkPost
'x-msys-api': JSON.stringify({
campaign_id: campaignAddress
}),
// custom header for SendGrid
'x-smtpapi': JSON.stringify({
unique_args: {
campaign_id: campaignAddress
}
}),
// custom header for Mailgun
'x-mailgun-variables': JSON.stringify({
campaign_id: campaignAddress
}),
'List-ID': {
prepared: true,
value: libmime.encodeWords(list.name) + ' <' + list.cid + '.' + getPublicUrl() + '>'
}
},
list: {
unsubscribe: null
},
subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false, false),
html,
text,
attachments,
encryptionKeys
};
let response;
try {
const info = await mailer.sendMassMail(mail);
response = info.response || info.messageId;
} catch (err) {
response = err.response || err.message;
}
return response;
}
async init(settings) {
this.listsById = new Map(); // listId -> list
this.listsByCid = new Map(); // listCid -> list
@ -51,14 +171,14 @@ class CampaignSender {
}
if (campaign.source === CampaignSource.TEMPLATE) {
this.template = await templates.getByIdTx(tx, contextHelpers.getAdminContext(), this.campaign.data.sourceTemplate, false);
this.template = await templates.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.data.sourceTemplate, false);
}
const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', this.campaign.id);
const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', campaign.id);
for (const attachment of attachments) {
this.attachments.push({
filename: attachment.originalname,
path: files.getFilePath('campaign', 'attachment', this.campaign.id, attachment.filename)
path: files.getFilePath('campaign', 'attachment', campaign.id, attachment.filename)
});
}
@ -111,7 +231,7 @@ class CampaignSender {
if (replaceDataImgs) {
// replace data: images with embedded attachments
html = html.replace(/(<img\b[^>]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
const cid = shortid.generate() + '-attachments@' + campaign.address.split('@').pop();
const cid = shortid.generate() + '-attachments';
attachments.push({
path: dataUri,
cid

View file

@ -254,6 +254,7 @@ async function getByIdTx(tx, context, id, withPermissions = true, content = Cont
} else if (content === Content.ONLY_SOURCE_CUSTOM) {
entity = {
id: entity.id,
send_configuration: entity.send_configuration,
data: {
sourceCustom: entity.data.sourceCustom
@ -502,13 +503,13 @@ function getMessageCid(campaignCid, listCid, subscriptionCid) {
}
async function getMessageByCid(messageCid) {
const messageCid = messageCid.split('.');
const messageCidElems = messageCid.split('.');
if (messageCid.length !== 3) {
if (messageCidElems.length !== 3) {
return null;
}
const [campaignCid, listCid, subscriptionCid] = messageCid;
const [campaignCid, listCid, subscriptionCid] = messageCidElems;
await knex.transaction(async tx => {
const list = await tx('lists').where('cid', listCid).select('id');

View file

@ -334,7 +334,7 @@ async function listGroupedTx(tx, listId) {
async function listGrouped(context, listId) {
return await knex.transaction(async tx => {
// It may seem odd why there is not 'manageFields' here. But it's just a result of strictly apply the "need-to-know" principle. Simply, at this point this function is needed only in managing subscriptions.
// It may seem odd why there is not 'viewFields' here. Simply, at this point this function is needed only in managing subscriptions.
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['manageSubscriptions']);
return await listGroupedTx(tx, listId);
});

View file

@ -34,6 +34,18 @@ async function listDTAjax(context, params) {
);
}
async function listWithSendPermissionDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[{ entityTypeId: 'sendConfiguration', requiredOperations: ['sendWithoutOverrides', 'sendWithAllowedOverrides', 'sendWithAnyOverrides'] }],
params,
builder => builder
.from('send_configurations')
.innerJoin('namespaces', 'namespaces.id', 'send_configurations.namespace'),
['send_configurations.id', 'send_configurations.name', 'send_configurations.cid', 'send_configurations.description', 'send_configurations.mailer_type', 'send_configurations.created', 'namespaces.name']
);
}
async function _getByTx(tx, context, key, id, withPermissions, withPrivateData) {
let entity;
@ -164,6 +176,7 @@ async function getSystemSendConfiguration() {
module.exports.MailerType = MailerType;
module.exports.hash = hash;
module.exports.listDTAjax = listDTAjax;
module.exports.listWithSendPermissionDTAjax = listWithSendPermissionDTAjax;
module.exports.getByIdTx = getByIdTx;
module.exports.getById = getById;
module.exports.getByCid = getByCid;

View file

@ -14,6 +14,7 @@ const moment = require('moment');
const { formatDate, formatBirthday } = require('../shared/date');
const crypto = require('crypto');
const campaigns = require('./campaigns');
const lists = require('./lists');
const allowedKeysBase = new Set(['email', 'tz', 'is_test', 'status']);
@ -340,6 +341,33 @@ async function listDTAjax(context, listId, segmentId, params) {
});
}
async function listTestUsersDTAjax(context, listCid, params) {
return await knex.transaction(async tx => {
const list = await lists.getByCidTx(tx, context, listCid);
await shares.enforceEntityPermissionTx(tx, context, 'list', list.id, 'viewSubscriptions');
const listTable = getSubscriptionTableName(list.id);
const columns = [
listTable + '.id',
listTable + '.cid',
listTable + '.email',
listTable + '.status',
listTable + '.created'
];
return await dtHelpers.ajaxListTx(
tx,
params,
builder => builder
.from(listTable)
.where('is_test', true),
columns,
{}
);
});
}
async function list(context, listId, grouped = true, offset, limit) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
@ -714,10 +742,10 @@ async function getListsWithEmail(context, email) {
// FIXME - this methods is rather suboptimal if there are many lists. It quite needs permission caching in shares.js
return await knex.transaction(async tx => {
const lists = await tx('lists').select(['id', 'name']);
const lsts = await tx('lists').select(['id', 'name']);
const result = [];
for (const list of lists) {
for (const list of lsts) {
await shares.enforceEntityPermissionTx(tx, context, 'list', list.id, 'viewSubscriptions');
const entity = await tx(getSubscriptionTableName(list.id)).where('email', email).first();
if (entity) {
@ -737,6 +765,7 @@ module.exports.getByCid = getByCid;
module.exports.getByEmail = getByEmail;
module.exports.list = list;
module.exports.listDTAjax = listDTAjax;
module.exports.listTestUsersDTAjax = listTestUsersDTAjax;
module.exports.serverValidate = serverValidate;
module.exports.create = create;
module.exports.getGroupedFieldsMap = getGroupedFieldsMap;

View file

@ -40,4 +40,8 @@ router.postAsync('/send-configurations-table', passport.loggedIn, async (req, re
return res.json(await sendConfigurations.listDTAjax(req.context, req.body));
});
router.postAsync('/send-configurations-with-send-permission-table', passport.loggedIn, async (req, res) => {
return res.json(await sendConfigurations.listWithSendPermissionDTAjax(req.context, req.body));
});
module.exports = router;

View file

@ -12,6 +12,10 @@ router.postAsync('/subscriptions-table/:listId/:segmentId?', passport.loggedIn,
return res.json(await subscriptions.listDTAjax(req.context, castToInteger(req.params.listId), req.params.segmentId ? castToInteger(req.params.segmentId) : null, req.body));
});
router.postAsync('/subscriptions-test-user-table/:listCid', passport.loggedIn, async (req, res) => {
return res.json(await subscriptions.listTestUsersDTAjax(req.context, req.params.listCid, req.body));
});
router.getAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, async (req, res) => {
const entity = await subscriptions.getById(req.context, castToInteger(req.params.listId), castToInteger(req.params.subscriptionId));
entity.hash = await subscriptions.hashByList(castToInteger(req.params.listId), entity);

View file

@ -5,6 +5,7 @@ const templates = require('../../models/templates');
const router = require('../../lib/router-async').create();
const {castToInteger} = require('../../lib/helpers');
const CampaignSender = require('../../lib/campaign-sender');
router.getAsync('/templates/:templateId', passport.loggedIn, async (req, res) => {
@ -34,4 +35,10 @@ router.postAsync('/templates-table', passport.loggedIn, async (req, res) => {
return res.json(await templates.listDTAjax(req.context, req.body));
});
router.postAsync('/template-test-send', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const data = req.body;
const result = await CampaignSender.testSend(req.context, data.listCid, data.subscriptionCid, data.campaignId, data.sendConfigurationId, data.html, data.text);
return res.json(result);
});
module.exports = router;