diff --git a/client/src/campaigns/Content.js b/client/src/campaigns/Content.js index a8a96c81..b79c60dc 100644 --- a/client/src/campaigns/Content.js +++ b/client/src/campaigns/Content.js @@ -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 (
+ this.setState({showTestSendModal: false})} + getDataAsync={this.sendModalGetDataHandler} + entity={this.props.entity} + /> + {t('Edit Custom Content')}
diff --git a/client/src/campaigns/TestSendModalDialog.js b/client/src/campaigns/TestSendModalDialog.js new file mode 100644 index 00000000..02f6b086 --- /dev/null +++ b/client/src/campaigns/TestSendModalDialog.js @@ -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 => {data} }, + { data: 3, title: t('List ID'), render: data => {data} }, + { data: 4, title: t('List') }, + { data: 5, title: t('List namespace') } + ]; + + return ( +
+ ); + } +} diff --git a/client/src/lib/bootstrap-components.js b/client/src/lib/bootstrap-components.js index ed38fbea..196a0a09 100644 --- a/client/src/lib/bootstrap-components.js +++ b/client/src/lib/bootstrap-components.js @@ -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 ( -
{ this.domModal = domElem; }} className="modal fade" tabIndex="-1" role="dialog" aria-labelledby="myModalLabel"> +
{ this.domModal = domElem; }} + className={'modal fade' + (props.className ? ' ' + props.className : '')} + tabIndex="-1" role="dialog" aria-labelledby="myModalLabel"> +
diff --git a/client/src/lib/sandboxed-ckeditor-root.js b/client/src/lib/sandboxed-ckeditor-root.js index cb78b30f..b0bfc114 100644 --- a/client/src/lib/sandboxed-ckeditor-root.js +++ b/client/src/lib/sandboxed-ckeditor-root.js @@ -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 = ''; + const postHtml = ''; + + 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 (
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} /> diff --git a/client/src/lib/sandboxed-ckeditor.js b/client/src/lib/sandboxed-ckeditor.js index 8f684351..7b724c9b 100644 --- a/client/src/lib/sandboxed-ckeditor.js +++ b/client/src/lib/sandboxed-ckeditor.js @@ -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 && }
{this.props.title}
+
this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="ckeditor/editor" tokenMethod="ckeditor" tokenParams={editorData}/>
diff --git a/client/src/lib/sandboxed-codeeditor-root.js b/client/src/lib/sandboxed-codeeditor-root.js index 7a6c3b97..3d17063f 100644 --- a/client/src/lib/sandboxed-codeeditor-root.js +++ b/client/src/lib/sandboxed-codeeditor-root.js @@ -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 (
@@ -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 &&
- +
}
diff --git a/client/src/lib/sandboxed-codeeditor.js b/client/src/lib/sandboxed-codeeditor.js index aed4caff..62bc1750 100644 --- a/client/src/lib/sandboxed-codeeditor.js +++ b/client/src/lib/sandboxed-codeeditor.js @@ -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 && }
{this.props.title}
+
this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="codeeditor/editor" tokenMethod="codeeditor" tokenParams={tokenData}/> diff --git a/client/src/lib/sandboxed-grapesjs-root.js b/client/src/lib/sandboxed-grapesjs-root.js index 2a4451e5..e53a1dfc 100644 --- a/client/src/lib/sandboxed-grapesjs-root.js +++ b/client/src/lib/sandboxed-grapesjs-root.js @@ -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 = ''; + const postHtml = ''; + html = preHtml + unbase(htmlBody, trustedUrlBase, sandboxUrlBase, publicUrlBase, true) + postHtml; } diff --git a/client/src/lib/sandboxed-grapesjs.js b/client/src/lib/sandboxed-grapesjs.js index 49a91239..5788f06a 100644 --- a/client/src/lib/sandboxed-grapesjs.js +++ b/client/src/lib/sandboxed-grapesjs.js @@ -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 && }
{this.props.title}
+
this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="grapesjs/editor" tokenMethod="grapesjs" tokenParams={tokenData}/>
diff --git a/client/src/lib/sandboxed-mosaico.js b/client/src/lib/sandboxed-mosaico.js index 98ea5897..9c97f8b0 100644 --- a/client/src/lib/sandboxed-mosaico.js +++ b/client/src/lib/sandboxed-mosaico.js @@ -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 && }
{this.props.title}
+
this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={tokenData}/>
diff --git a/client/src/lib/styles.scss b/client/src/lib/styles.scss index 9e085402..3424fd5c 100644 --- a/client/src/lib/styles.scss +++ b/client/src/lib/styles.scss @@ -145,4 +145,16 @@ .dependenciesList { margin-bottom: 0px; -} \ No newline at end of file +} + + +:global .modal-dialog { + @media (min-width: 768px) { + width: 700px; + } + + @media (min-width: 1000px) { + width: 900px; + } +} + diff --git a/client/src/templates/CUD.js b/client/src/templates/CUD.js index 945e8b9d..881feed6 100644 --- a/client/src/templates/CUD.js +++ b/client/src/templates/CUD.js @@ -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 (
+ {isEdit && + this.setState({showTestSendModal: false})} + getDataAsync={this.sendModalGetDataHandler}/> + } {canDelete &&
diff --git a/client/src/templates/TestSendModalDialog.js b/client/src/templates/TestSendModalDialog.js new file mode 100644 index 00000000..b7f1e82f --- /dev/null +++ b/client/src/templates/TestSendModalDialog.js @@ -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 => {data} }, + { data: 2, title: t('Email') } + ]; + + const listsColumns = [ + { data: 1, title: t('Name') }, + { data: 2, title: t('ID'), render: data => {data} }, + { 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 => {data} }, + { 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 ( + + ); + } +} diff --git a/client/src/templates/helpers.js b/client/src/templates/helpers.js index 09ca8ca4..9bca4b81 100644 --- a/client/src/templates/helpers.js +++ b/client/src/templates/helpers.js @@ -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} + /> , exportHTMLEditorData: async owner => { const {html, metadata, model} = await owner.editorNode.exportState(); - owner.updateFormValue(prefix + 'html', html); - owner.updateFormValue(prefix + 'mosaicoData', { - metadata, - model - }); + 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} + /> , exportHTMLEditorData: async owner => { const {html, metadata, model} = await owner.editorNode.exportState(); - owner.updateFormValue(prefix + 'html', html); - owner.updateFormValue(prefix + 'mosaicoData', { - metadata, - model - }); + 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} /> , exportHTMLEditorData: async owner => { const {html, source, style} = await owner.editorNode.exportState(); - owner.updateFormValue(prefix + 'html', html); - owner.updateFormValue(prefix + 'grapesJSData', { - source, - style - }); + 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 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} /> , 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 => , - exportHTMLEditorData: async owner => {}, - initData: () => ({}), - afterLoad: data => {}, + getHTMLEditor: owner => , + exportHTMLEditorData: async owner => { + const preHtml = ''; + const postHtml = ''; + + 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} /> , exportHTMLEditorData: async owner => { const {html, source} = await owner.editorNode.exportState(); - owner.updateFormValue(prefix + 'html', html); - owner.updateFormValue(prefix + 'codeEditorData', { - source - }); + return { + [prefix + 'html']: html, + [prefix + 'codeEditorData']: { + source + } + }; }, initData: () => ({ [prefix + 'codeEditorSourceType']: CodeEditorSourceType.HTML, diff --git a/lib/campaign-sender.js b/lib/campaign-sender.js index 38c3b0f4..944cad37 100644 --- a/lib/campaign-sender.js +++ b/lib/campaign-sender.js @@ -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(/(]* 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(/(]* 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 diff --git a/models/campaigns.js b/models/campaigns.js index bbe84439..04aadde1 100644 --- a/models/campaigns.js +++ b/models/campaigns.js @@ -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'); diff --git a/models/fields.js b/models/fields.js index 2f6a3a40..158f7ef9 100644 --- a/models/fields.js +++ b/models/fields.js @@ -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); }); diff --git a/models/send-configurations.js b/models/send-configurations.js index 0c17ebc5..991b0ec1 100644 --- a/models/send-configurations.js +++ b/models/send-configurations.js @@ -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; diff --git a/models/subscriptions.js b/models/subscriptions.js index e5a6e3e5..e4b828f1 100644 --- a/models/subscriptions.js +++ b/models/subscriptions.js @@ -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; diff --git a/routes/rest/send-configurations.js b/routes/rest/send-configurations.js index cc230ff9..7ad2c1c2 100644 --- a/routes/rest/send-configurations.js +++ b/routes/rest/send-configurations.js @@ -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; \ No newline at end of file diff --git a/routes/rest/subscriptions.js b/routes/rest/subscriptions.js index 8dbb1e89..c7f841fb 100644 --- a/routes/rest/subscriptions.js +++ b/routes/rest/subscriptions.js @@ -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); diff --git a/routes/rest/templates.js b/routes/rest/templates.js index 51131bd6..216b065f 100644 --- a/routes/rest/templates.js +++ b/routes/rest/templates.js @@ -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; \ No newline at end of file