Merge pull request #1 from Mailtrain-org/development

Merge
This commit is contained in:
Carlos Zamora Sanz 2019-08-05 09:20:13 +02:00 committed by GitHub
commit b7fbc32e1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
164 changed files with 9649 additions and 7751 deletions

View file

@ -174,7 +174,7 @@ To make this publicly accessible, you should add reverse proxy that makes these
An example of such proxy would be: An example of such proxy would be:
- http://localhost:3000 -> https://mailtrain.example.com - http://localhost:3000 -> https://mailtrain.example.com
- http://localhost:3003 -> https://sbox.mailtrain.example.com - http://localhost:3003 -> https://sbox.mailtrain.example.com
- http://localhsot:3004 -> https://lists.example.com - http://localhost:3004 -> https://lists.example.com
To deploy Mailtrain with Docker, you need the following three dependencies installed: To deploy Mailtrain with Docker, you need the following three dependencies installed:

View file

@ -21,3 +21,6 @@ Note that some of these may be already obsolete...
- Add field to subscriptions which says till when the consent has been given - Add field to subscriptions which says till when the consent has been given
- Provide a link (and merge tag) that will update the consent date to now - Provide a link (and merge tag) that will update the consent date to now
- Add campaign trigger that triggers if the consent for specific subscription field is about to expire (i.e. it is greater than now - seconds) - Add campaign trigger that triggers if the consent for specific subscription field is about to expire (i.e. it is greater than now - seconds)
### RSS Campaigns
- Aggregated RSS campaigns

1705
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -20,16 +20,15 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"homepage": "https://mailtrain.org/", "homepage": "https://mailtrain.org/",
"dependencies": { "dependencies": {
"@coreui/coreui": "^2.1.4", "@coreui/coreui": "^2.1.12",
"@fortawesome/fontawesome-free": "^5.6.3", "@fortawesome/fontawesome-free": "^5.9.0",
"axios": "^0.18.0", "axios": "^0.18.1",
"bootstrap": "^4.2.1", "bootstrap": "^4.2.1",
"clone": "^2.1.2", "clone": "^2.1.2",
"datatables.net": "^1.10.19", "datatables.net": "^1.10.19",
"datatables.net-bs4": "^1.10.19", "datatables.net-bs4": "^1.10.19",
"ellipsize": "^0.1.0", "ellipsize": "^0.1.0",
"fast-deep-equal": "^2.0.1", "grapesjs": "^0.14.62",
"grapesjs": "^0.14.49",
"grapesjs-mjml": "0.0.31", "grapesjs-mjml": "0.0.31",
"grapesjs-preset-newsletter": "^0.2.20", "grapesjs-preset-newsletter": "^0.2.20",
"htmlparser2": "^3.10.1", "htmlparser2": "^3.10.1",
@ -37,51 +36,51 @@
"i18next-browser-languagedetector": "^2.2.4", "i18next-browser-languagedetector": "^2.2.4",
"immutable": "^4.0.0-rc.12", "immutable": "^4.0.0-rc.12",
"juice": "^5.2.0", "juice": "^5.2.0",
"lodash": "^4.17.11", "lodash": "^4.17.15",
"mjml4-in-browser": "^1.1.1", "mjml4-in-browser": "^1.1.2",
"moment": "^2.23.0", "moment": "^2.23.0",
"moment-timezone": "^0.5.25", "moment-timezone": "^0.5.26",
"popper.js": "^1.14.6", "popper.js": "^1.14.6",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"querystringify": "^2.1.0", "querystringify": "^2.1.0",
"react": "^16.7.0", "react": "^16.7.0",
"react-ace": "^6.3.2", "react-ace": "^6.6.0",
"react-ckeditor-component": "^1.1.0", "react-ckeditor-component": "^1.1.0",
"react-color": "^2.17.0", "react-color": "^2.17.3",
"react-day-picker": "^7.2.4", "react-day-picker": "^7.2.4",
"react-dnd": "^7.0.2", "react-dnd": "^7.7.0",
"react-dnd-html5-backend": "^7.0.2", "react-dnd-html5-backend": "^7.7.0",
"react-dnd-touch-backend": "^0.7.1", "react-dnd-touch-backend": "^0.7.1",
"react-dom": "^16.7.0", "react-dom": "^16.7.0",
"react-dropzone": "^8.0.3", "react-dropzone": "^8.0.3",
"react-google-charts": "^3.0.10", "react-google-charts": "^3.0.14",
"react-i18next": "^9.0.1", "react-i18next": "^9.0.1",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",
"react-sortable-tree": "^2.6.0", "react-sortable-tree": "^2.6.0",
"shallowequal": "^1.1.0", "shallowequal": "^1.1.0",
"shortid": "^2.2.14", "shortid": "^2.2.14",
"slugify": "^1.3.4", "slugify": "^1.3.4",
"url-parse": "^1.4.4" "url-parse": "^1.4.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.2.3", "@babel/cli": "^7.5.5",
"@babel/core": "^7.2.2", "@babel/core": "^7.5.5",
"@babel/plugin-proposal-class-properties": "^7.2.3", "@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-proposal-decorators": "^7.2.3", "@babel/plugin-proposal-decorators": "^7.4.4",
"@babel/plugin-proposal-function-bind": "^7.2.0", "@babel/plugin-proposal-function-bind": "^7.2.0",
"@babel/preset-env": "^7.2.3", "@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.4", "babel-loader": "^8.0.6",
"clean-css-cli": "^4.2.1", "clean-css-cli": "^4.2.1",
"css-loader": "^2.1.0", "css-loader": "^2.1.0",
"file-loader": "^3.0.1", "file-loader": "^3.0.1",
"node-sass": "^4.11.0", "node-sass": "^4.12.0",
"nodemon": "^1.18.9", "nodemon": "^1.19.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"url-loader": "^1.1.2", "url-loader": "^1.1.2",
"webpack": "^4.28.3", "webpack": "^4.36.1",
"webpack-cli": "^3.2.0" "webpack-cli": "^3.3.6"
} }
} }

View file

@ -19,8 +19,8 @@ export default class List extends Component {
return ( return (
<div> <div>
<h2>{t('welcomeToMailtrain')}</h2> <h2>{t('Mailtrain 2 beta')}</h2>
<div>TODO: some dashboard</div> <div>{t('Build') + ' 2019-08-01-0745'}</div>
</div> </div>
); );
} }

View file

@ -103,7 +103,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li> <li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
@ -138,7 +138,7 @@ export default class API extends Component {
<strong>{t('example')}</strong> <strong>{t('example')}</strong>
</p> </p>
<pre>curl -XPOST '{getUrl(`api/subscribe/B16uVTdW?access_token=${accessToken}`)}' \ <pre>curl -XPOST '{getUrl(`api/subscribe/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;MERGE_CHECKBOX=yes&amp;REQUIRE_CONFIRMATION=yes'</pre> --data 'EMAIL=test@example.com&amp;MERGE_CHECKBOX=yes&amp;REQUIRE_CONFIRMATION=yes'</pre>
<h4>POST /api/unsubscribe/:listId {t('removeSubscription')}</h4> <h4>POST /api/unsubscribe/:listId {t('removeSubscription')}</h4>
@ -148,7 +148,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li> <li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
@ -165,7 +165,7 @@ export default class API extends Component {
<strong>{t('example')}</strong> <strong>{t('example')}</strong>
</p> </p>
<pre>curl -XPOST '{getUrl(`api/unsubscribe/B16uVTdW?access_token=${accessToken}`)}' \ <pre>curl -XPOST '{getUrl(`api/unsubscribe/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com'</pre> --data 'EMAIL=test@example.com'</pre>
<h4>POST /api/delete/:listId {t('deleteSubscription')}</h4> <h4>POST /api/delete/:listId {t('deleteSubscription')}</h4>
@ -175,7 +175,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li> <li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
@ -192,7 +192,7 @@ export default class API extends Component {
<strong>{t('example')}</strong> <strong>{t('example')}</strong>
</p> </p>
<pre>curl -XPOST '{getUrl(`api/delete/B16uVTdW?access_token=${accessToken}`)}' \ <pre>curl -XPOST '{getUrl(`api/delete/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com'</pre> --data 'EMAIL=test@example.com'</pre>
<h4>POST /api/field/:listId {t('addNewCustomField')}</h4> <h4>POST /api/field/:listId {t('addNewCustomField')}</h4>
@ -202,7 +202,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li> <li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
@ -240,7 +240,7 @@ export default class API extends Component {
<strong>{t('example')}</strong> <strong>{t('example')}</strong>
</p> </p>
<pre>curl -XPOST '{getUrl(`api/field/B16uVTdW?access_token=${accessToken}`)}' \ <pre>curl -XPOST '{getUrl(`api/field/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'NAME=Birthday&amp;TYPE=birthday-us&amp;VISIBLE=yes'</pre> --data 'NAME=Birthday&amp;TYPE=birthday-us&amp;VISIBLE=yes'</pre>
<h4>GET /api/blacklist/get {t('getListOfBlacklistedEmails')}</h4> <h4>GET /api/blacklist/get {t('getListOfBlacklistedEmails')}</h4>
@ -250,7 +250,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')} <li><strong>access_token</strong> {t('yourPersonalAccessToken')}
@ -275,7 +275,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li> <li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
@ -292,7 +292,7 @@ export default class API extends Component {
<strong>{t('example')}</strong> <strong>{t('example')}</strong>
</p> </p>
<pre>curl -XPOST '{getUrl(`api/blacklist/add?access_token={accessToken}`)}' \ <pre>curl -XPOST '{getUrl(`api/blacklist/add?access_token={accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;'</pre> --data 'EMAIL=test@example.com&amp;'</pre>
<h4>POST /api/blacklist/delete {t('deleteEmailFromBlacklist')}</h4> <h4>POST /api/blacklist/delete {t('deleteEmailFromBlacklist')}</h4>
@ -302,7 +302,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li> <li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
@ -319,7 +319,7 @@ export default class API extends Component {
<strong>{t('example')}</strong> <strong>{t('example')}</strong>
</p> </p>
<pre>curl -XPOST '{getUrl(`api/blacklist/delete?access_token=${accessToken}`)}' \ <pre>curl -XPOST '{getUrl(`api/blacklist/delete?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;'</pre> --data 'EMAIL=test@example.com&amp;'</pre>
<h4>GET /api/lists/:email {t('getTheListsAUserHasSubscribedTo')}</h4> <h4>GET /api/lists/:email {t('getTheListsAUserHasSubscribedTo')}</h4>
@ -329,7 +329,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li> <li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
@ -349,7 +349,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li> <li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
@ -368,7 +368,7 @@ export default class API extends Component {
</p> </p>
<p> <p>
<strong>GET</strong> {t('arguments')} {t('Query params')}
</p> </p>
<ul> <ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li> <li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
@ -381,16 +381,16 @@ export default class API extends Component {
<li><strong>EMAIL</strong> {t('emailAddress')} (<em>{t('required')}</em>)</li> <li><strong>EMAIL</strong> {t('emailAddress')} (<em>{t('required')}</em>)</li>
<li><strong>SEND_CONFIGURATION_ID</strong> {t('idOfConfigurationUsedToCreateMailer')}</li> <li><strong>SEND_CONFIGURATION_ID</strong> {t('idOfConfigurationUsedToCreateMailer')}</li>
<li><strong>SUBJECT</strong> {t('subject')}</li> <li><strong>SUBJECT</strong> {t('subject')}</li>
<li><strong>DATA</strong> {t('dataPassedToTemplateWhenCompilingWith')}: <em>{'{'} "any": ["type", {'{'}"of": "data"{'}'}] {'}'}</em></li> <li><strong>TAGS</strong> {t('mapOfTemplatesubjectVariablesToReplace')}</li>
<li><strong>VARIABLES</strong> {t('mapOfTemplatesubjectVariablesToReplace')}: <em>{'{'} "FOO": "bar" {'}'}</em></li> <li><strong>ATTACHMENTS</strong> {t('Attachments (format as consumed by nodemailer)')}</li>
</ul> </ul>
<p> <p>
<strong>{t('example')}</strong> <strong>{t('example')}</strong>
</p> </p>
<pre>curl -XPOST '{getUrl(`api/templates/1/send?access_token={accessToken}`)}' \ <pre>curl -XPOST '{getUrl(`api/templates/1/send?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;SUBJECT=Test&amp;VARIABLES[FOO]=bar&amp;VARIABLES[TEST]=example'</pre> --data 'EMAIL=test@example.com&amp;SUBJECT=Test&amp;TAGS[FOO]=bar&amp;TAGS[TEST]=example'</pre>
</div> </div>
); );
} }

View file

@ -22,10 +22,10 @@ import {
withFormErrorHandlers withFormErrorHandlers
} from '../lib/form'; } from '../lib/form';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling'; import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import {NamespaceSelect, validateNamespace} from '../lib/namespace'; import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals"; import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers'; import {getTagLanguages, getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers';
import axios from '../lib/axios'; import axios from '../lib/axios';
import styles from "../lib/styles.scss"; import styles from "../lib/styles.scss";
import campaignsStyles from "./styles.scss"; import campaignsStyles from "./styles.scss";
@ -50,6 +50,8 @@ export default class CUD extends Component {
const t = props.t; const t = props.t;
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN); this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN);
this.tagLanguages = getTagLanguages(props.t);
this.mailerTypes = getMailerTypes(props.t); this.mailerTypes = getMailerTypes(props.t);
const { campaignTypeLabels } = getCampaignLabels(t); const { campaignTypeLabels } = getCampaignLabels(t);
@ -68,15 +70,19 @@ export default class CUD extends Component {
}; };
this.sourceLabels = { this.sourceLabels = {
[CampaignSource.CUSTOM]: t('customContent'),
[CampaignSource.CUSTOM_FROM_CAMPAIGN]: t('customContentClonedFromAnotherCampaign'),
[CampaignSource.TEMPLATE]: t('template'), [CampaignSource.TEMPLATE]: t('template'),
[CampaignSource.CUSTOM_FROM_TEMPLATE]: t('customContentClonedFromTemplate'), [CampaignSource.CUSTOM_FROM_TEMPLATE]: t('customContentClonedFromTemplate'),
[CampaignSource.CUSTOM_FROM_CAMPAIGN]: t('customContentClonedFromAnotherCampaign'),
[CampaignSource.CUSTOM]: t('customContent'),
[CampaignSource.URL]: t('url') [CampaignSource.URL]: t('url')
}; };
const sourceLabelsOrder = [
CampaignSource.CUSTOM, CampaignSource.CUSTOM_FROM_CAMPAIGN , CampaignSource.TEMPLATE, CampaignSource.CUSTOM_FROM_TEMPLATE, CampaignSource.URL
];
this.sourceOptions = []; this.sourceOptions = [];
for (const key in this.sourceLabels) { for (const key in sourceLabelsOrder) {
this.sourceOptions.push({key, label: this.sourceLabels[key]}); this.sourceOptions.push({key, label: this.sourceLabels[key]});
} }
@ -85,6 +91,11 @@ export default class CUD extends Component {
this.customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName}); this.customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName});
} }
this.customTemplateTagLanguageOptions = [];
for (const key of mailtrainConfig.tagLanguages) {
this.customTemplateTagLanguageOptions.push({key, label: this.tagLanguages[key].name});
}
this.state = { this.state = {
sendConfiguration: null sendConfiguration: null
}; };
@ -102,6 +113,7 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
entity: PropTypes.object, entity: PropTypes.object,
permissions: PropTypes.object,
type: PropTypes.number type: PropTypes.number
} }
@ -114,13 +126,20 @@ export default class CUD extends Component {
onFormChangeBeforeValidation(mutStateData, key, oldValue, newValue) { onFormChangeBeforeValidation(mutStateData, key, oldValue, newValue) {
let match; let match;
if (key === undefined || key === 'data_sourceCustom_type') { if (key === 'data_sourceCustom_type') {
if (newValue) { if (newValue) {
this.templateTypes[newValue].afterTypeChange(mutStateData); this.templateTypes[newValue].afterTypeChange(mutStateData);
} }
} }
if (key === undefined || (match = key.match(/^(lists_[0-9]+_)list$/))) { if (key === 'data_sourceCustom_tag_language') {
if (newValue) {
const isEdit = !!this.props.entity;
this.templateTypes[newValue].afterTagLanguageChange(mutStateData, isEdit);
}
}
if (key && (match = key.match(/^(lists_[0-9]+_)list$/))) {
const prefix = match[1]; const prefix = match[1];
mutStateData.setIn([prefix + 'segment', 'value'], null); mutStateData.setIn([prefix + 'segment', 'value'], null);
} }
@ -162,7 +181,12 @@ export default class CUD extends Component {
} }
for (const overridable of campaignOverridables) { for (const overridable of campaignOverridables) {
data[overridable + '_overriden'] = data[overridable + '_override'] !== null; if (data[overridable + '_override'] === null) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
} else {
data[overridable + '_overriden'] = true;
}
} }
const lsts = []; const lsts = [];
@ -202,6 +226,7 @@ export default class CUD extends Component {
data.data.sourceCustom = { data.data.sourceCustom = {
type: data.data_sourceCustom_type, type: data.data_sourceCustom_type,
tag_language: data.data_sourceCustom_tag_language,
data: data.data_sourceCustom_data, data: data.data_sourceCustom_data,
html: data.data_sourceCustom_html, html: data.data_sourceCustom_html,
text: data.data_sourceCustom_text text: data.data_sourceCustom_text
@ -244,7 +269,7 @@ export default class CUD extends Component {
return filterData(data, [ return filterData(data, [
'name', 'description', 'segment', 'namespace', 'send_configuration', 'name', 'description', 'segment', 'namespace', 'send_configuration',
'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override', 'subject', 'from_name_override', 'from_email_override', 'reply_to_override',
'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url',
'type', 'source', 'parent', 'lists' 'type', 'source', 'parent', 'lists'
]); ]);
@ -282,14 +307,16 @@ export default class CUD extends Component {
lists: [lstUid], lists: [lstUid],
send_configuration: null, send_configuration: null,
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
subject: '',
click_tracking_disabled: false, click_tracking_disabled: false,
open_tracking_disabled: false, open_tracking_disabled: false,
unsubscribe_url: '', unsubscribe_url: '',
source: CampaignSource.TEMPLATE, source: CampaignSource.CUSTOM,
// This is for CampaignSource.TEMPLATE and CampaignSource.CUSTOM_FROM_TEMPLATE // This is for CampaignSource.TEMPLATE and CampaignSource.CUSTOM_FROM_TEMPLATE
data_sourceTemplate: null, data_sourceTemplate: null,
@ -299,6 +326,7 @@ export default class CUD extends Component {
// This is for CampaignSource.CUSTOM // This is for CampaignSource.CUSTOM
data_sourceCustom_type: mailtrainConfig.editors[0], data_sourceCustom_type: mailtrainConfig.editors[0],
data_sourceCustom_tag_language: mailtrainConfig.tagLanguages[0],
data_sourceCustom_data: {}, data_sourceCustom_data: {},
data_sourceCustom_html: '', data_sourceCustom_html: '',
data_sourceCustom_text: '', data_sourceCustom_text: '',
@ -326,6 +354,10 @@ export default class CUD extends Component {
state.setIn(['name', 'error'], t('nameMustNotBeEmpty')); state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
} }
if (!state.getIn(['subject', 'value'])) {
state.setIn(['subject', 'error'], t('"Subject" line must not be empty"'));
}
if (!state.getIn(['send_configuration', 'value'])) { if (!state.getIn(['send_configuration', 'value'])) {
state.setIn(['send_configuration', 'error'], t('sendConfigurationMustBeSelected')); state.setIn(['send_configuration', 'error'], t('sendConfigurationMustBeSelected'));
} }
@ -356,6 +388,10 @@ export default class CUD extends Component {
state.setIn(['data_sourceCustom_type', 'error'], t('typeMustBeSelected')); state.setIn(['data_sourceCustom_type', 'error'], t('typeMustBeSelected'));
} }
if (!state.getIn(['data_sourceCustom_tag_language', 'value'])) {
state.setIn(['data_sourceCustom_tag_language', 'error'], t('Tag language must be selected'));
}
if (customTemplateTypeKey) { if (customTemplateTypeKey) {
this.templateTypes[customTemplateTypeKey].validate(state); this.templateTypes[customTemplateTypeKey].validate(state);
} }
@ -592,7 +628,6 @@ export default class CUD extends Component {
{ data: 2, title: t('id'), render: data => <code>{data}</code> }, { data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') }, { data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.mailerTypes[data].typeName }, { 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') } { data: 6, title: t('namespace') }
]; ];
@ -604,10 +639,10 @@ export default class CUD extends Component {
const addOverridable = (id, label) => { const addOverridable = (id, label) => {
if(this.state.sendConfiguration[id + '_overridable']){ if(this.state.sendConfiguration[id + '_overridable']){
if (this.getFormValue(id + '_overriden')) { if (this.getFormValue(id + '_overriden')) {
sendSettings.push(<InputField label={t(label)} key={id + '_override'} id={id + '_override'}/>); sendSettings.push(<InputField label={label} key={id + '_override'} id={id + '_override'}/>);
} else { } else {
sendSettings.push( sendSettings.push(
<StaticField key={id + '_original'} label={t(label)} id={id + '_original'} className={styles.formDisabled}> <StaticField key={id + '_original'} label={label} id={id + '_original'} className={styles.formDisabled}>
{this.state.sendConfiguration[id]} {this.state.sendConfiguration[id]}
</StaticField> </StaticField>
); );
@ -616,7 +651,7 @@ export default class CUD extends Component {
} }
else{ else{
sendSettings.push( sendSettings.push(
<StaticField key={id + '_original'} label={t(label)} id={id + '_original'} className={styles.formDisabled}> <StaticField key={id + '_original'} label={label} id={id + '_original'} className={styles.formDisabled}>
{this.state.sendConfiguration[id]} {this.state.sendConfiguration[id]}
</StaticField> </StaticField>
); );
@ -626,7 +661,6 @@ export default class CUD extends Component {
addOverridable('from_name', t('fromName')); addOverridable('from_name', t('fromName'));
addOverridable('from_email', t('fromEmailAddress')); addOverridable('from_email', t('fromEmailAddress'));
addOverridable('reply_to', t('replytoEmailAddress')); addOverridable('reply_to', t('replytoEmailAddress'));
addOverridable('subject', t('subjectLine'));
} else { } else {
sendSettings = <AlignedRow>{t('loadingSendConfiguration')}</AlignedRow> sendSettings = <AlignedRow>{t('loadingSendConfiguration')}</AlignedRow>
} }
@ -650,8 +684,8 @@ export default class CUD extends Component {
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
{ data: 2, title: t('description') }, { data: 2, title: t('description') },
{ data: 3, title: t('type'), render: data => this.templateTypes[data].typeName }, { 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('created'), render: data => moment(data).fromNow() },
{ data: 5, title: t('namespace') }, { data: 6, title: t('namespace') },
]; ];
let help = null; let help = null;
@ -686,6 +720,8 @@ export default class CUD extends Component {
templateEdit = <div> templateEdit = <div>
<Dropdown id="data_sourceCustom_type" label={t('type')} options={this.customTemplateTypeOptions}/> <Dropdown id="data_sourceCustom_type" label={t('type')} options={this.customTemplateTypeOptions}/>
<Dropdown id="data_sourceCustom_tag_language" label={t('Tag language')} options={this.customTemplateTagLanguageOptions} disabled={isEdit && (!customTemplateTypeKey || this.templateTypes[customTemplateTypeKey].isTagLanguageSelectorDisabledForEdit)}/>
{customTemplateTypeForm} {customTemplateTypeForm}
</div>; </div>;
@ -741,6 +777,8 @@ export default class CUD extends Component {
{sendSettings} {sendSettings}
<InputField label={t('subjectLine')} key="subject" id="subject"/>
<InputField id="unsubscribe_url" label={t('customUnsubscribeUrl')}/> <InputField id="unsubscribe_url" label={t('customUnsubscribeUrl')}/>
</Fieldset> </Fieldset>
@ -760,8 +798,6 @@ export default class CUD extends Component {
</> </>
} }
{templateEdit} {templateEdit}
<ButtonRow> <ButtonRow>

View file

@ -7,6 +7,7 @@ import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
import { import {
Button, Button,
ButtonRow, ButtonRow,
Dropdown,
filterData, filterData,
Form, Form,
FormSendMethod, FormSendMethod,
@ -16,11 +17,11 @@ import {
} from '../lib/form'; } from '../lib/form';
import {withErrorHandling} from '../lib/error-handling'; import {withErrorHandling} from '../lib/error-handling';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {getEditForm, getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers'; import {getEditForm, getTagLanguages, getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers';
import axios from '../lib/axios'; import axios from '../lib/axios';
import styles from "../lib/styles.scss"; import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls"; import {getUrl} from "../lib/urls";
import {TestSendModalDialog} from "./TestSendModalDialog"; import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import {ContentModalDialog} from "../lib/modals"; import {ContentModalDialog} from "../lib/modals";
@ -39,12 +40,18 @@ export default class CustomContent extends Component {
const t = props.t; const t = props.t;
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN); this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN);
this.tagLanguages = getTagLanguages(props.t);
this.customTemplateTypeOptions = []; this.customTemplateTypeOptions = [];
for (const key of mailtrainConfig.editors) { for (const key of mailtrainConfig.editors) {
this.customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName}); this.customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName});
} }
this.customTemplateTagLanguageOptions = [];
for (const key of mailtrainConfig.tagLanguages) {
this.customTemplateTagLanguageOptions.push({key, label: this.tagLanguages[key].name});
}
this.state = { this.state = {
showMergeTagReference: false, showMergeTagReference: false,
elementInFullscreen: false, elementInFullscreen: false,
@ -56,6 +63,9 @@ export default class CustomContent extends Component {
this.initForm({ this.initForm({
getPreSubmitUpdater: ::this.getPreSubmitFormValuesUpdater, getPreSubmitUpdater: ::this.getPreSubmitFormValuesUpdater,
onChangeBeforeValidation: {
data_sourceCustom_tag_language: ::this.onTagLanguageChanged
}
}); });
this.sendModalGetDataHandler = ::this.sendModalGetData; this.sendModalGetDataHandler = ::this.sendModalGetData;
@ -71,9 +81,16 @@ export default class CustomContent extends Component {
setPanelInFullScreen: PropTypes.func setPanelInFullScreen: PropTypes.func
} }
onTagLanguageChanged(mutStateData, key, oldTagLanguage, tagLanguage) {
if (tagLanguage) {
const type = mutStateData.getIn(['data_sourceCustom_tag_language', 'value']);
this.tagLanguages[type].afterTagLanguageChange(mutStateData, true);
}
}
getFormValuesMutator(data) { getFormValuesMutator(data) {
data.data_sourceCustom_type = data.data.sourceCustom.type; data.data_sourceCustom_type = data.data.sourceCustom.type;
data.data_sourceCustom_tag_language = data.data.sourceCustom.tag_language;
data.data_sourceCustom_data = data.data.sourceCustom.data; data.data_sourceCustom_data = data.data.sourceCustom.data;
data.data_sourceCustom_html = data.data.sourceCustom.html; data.data_sourceCustom_html = data.data.sourceCustom.html;
data.data_sourceCustom_text = data.data.sourceCustom.text; data.data_sourceCustom_text = data.data.sourceCustom.text;
@ -86,6 +103,7 @@ export default class CustomContent extends Component {
data.data.sourceCustom = { data.data.sourceCustom = {
type: data.data_sourceCustom_type, type: data.data_sourceCustom_type,
tag_language: data.data_sourceCustom_tag_language,
data: data.data_sourceCustom_data, data: data.data_sourceCustom_data,
html: data.data_sourceCustom_html, html: data.data_sourceCustom_html,
text: data.data_sourceCustom_text text: data.data_sourceCustom_text
@ -112,6 +130,12 @@ export default class CustomContent extends Component {
localValidateFormValues(state) { localValidateFormValues(state) {
const t = this.props.t; const t = this.props.t;
if (!state.getIn(['data_sourceCustom_tag_language', 'value'])) {
state.setIn(['data_sourceCustom_tag_language', 'error'], t('Tag language must be selected'));
} else {
state.setIn(['data_sourceCustom_tag_language', 'error'], null);
}
const customTemplateTypeKey = state.getIn(['data_sourceCustom_type', 'value']); const customTemplateTypeKey = state.getIn(['data_sourceCustom_type', 'value']);
if (customTemplateTypeKey) { if (customTemplateTypeKey) {
@ -205,7 +229,8 @@ export default class CustomContent extends Component {
return { return {
html: exportedData.data_sourceCustom_html, html: exportedData.data_sourceCustom_html,
text: this.getFormValue('data_sourceCustom_text') text: this.getFormValue('data_sourceCustom_text'),
tagLanguage: this.getFormValue('data_sourceCustom_tag_language')
}; };
} }
@ -229,15 +254,14 @@ export default class CustomContent extends Component {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
// FIXME - data_sourceCustom_type is initialized only after first render
return ( return (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}> <div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
<TestSendModalDialog <TestSendModalDialog
mode={TestSendModalDialogMode.CAMPAIGN_CONTENT}
visible={this.state.showTestSendModal} visible={this.state.showTestSendModal}
onHide={() => this.setState({showTestSendModal: false})} onHide={() => this.setState({showTestSendModal: false})}
getDataAsync={this.sendModalGetDataHandler} getDataAsync={this.sendModalGetDataHandler}
entity={this.props.entity} campaign={this.props.entity}
/> />
<ContentModalDialog <ContentModalDialog
title={this.state.exportModalTitle} title={this.state.exportModalTitle}
@ -253,6 +277,8 @@ export default class CustomContent extends Component {
{customTemplateTypeKey && this.templateTypes[customTemplateTypeKey].typeName} {customTemplateTypeKey && this.templateTypes[customTemplateTypeKey].typeName}
</StaticField> </StaticField>
<Dropdown id="data_sourceCustom_tag_language" label={t('Tag language')} options={this.customTemplateTagLanguageOptions} disabled={!customTemplateTypeKey || this.templateTypes[customTemplateTypeKey].isTagLanguageSelectorDisabledForEdit}/>
{customTemplateTypeKey && getTypeForm(this, customTemplateTypeKey, true)} {customTemplateTypeKey && getTypeForm(this, customTemplateTypeKey, true)}
{customTemplateTypeKey && getEditForm(this, customTemplateTypeKey, 'data_sourceCustom_')} {customTemplateTypeKey && getEditForm(this, customTemplateTypeKey, 'data_sourceCustom_')}
@ -261,7 +287,7 @@ export default class CustomContent extends Component {
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(CustomContent.AfterSubmitAction.LEAVE)}/> <Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(CustomContent.AfterSubmitAction.LEAVE)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndGoToStatus')} onClickAsync={async () => await this.submitHandler(CustomContent.AfterSubmitAction.STATUS)}/> <Button type="submit" className="btn-primary" icon="check" label={t('saveAndGoToStatus')} onClickAsync={async () => await this.submitHandler(CustomContent.AfterSubmitAction.STATUS)}/>
<Button className="btn-success" icon="at" label={t('testSend')} onClickAsync={async () => this.setState({showTestSendModal: true})}/> <Button className="btn-success" icon="at" label={t('Test send')} onClickAsync={async () => this.setState({showTestSendModal: true})}/>
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -4,15 +4,15 @@ import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n'; import {withTranslation} from '../lib/i18n';
import {ButtonDropdown, Icon} from '../lib/bootstrap-components'; import {ButtonDropdown, Icon} from '../lib/bootstrap-components';
import {DropdownLink, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page'; import {DropdownLink, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling'; import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table'; import {Table} from '../lib/table';
import moment from 'moment'; import moment from 'moment';
import {CampaignSource, CampaignStatus, CampaignType} from "../../../shared/campaigns"; import {CampaignSource, CampaignStatus, CampaignType} from "../../../shared/campaigns";
import {checkPermissions} from "../lib/permissions";
import {getCampaignLabels} from "./helpers"; import {getCampaignLabels} from "./helpers";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals"; import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import styles from "./styles.scss"; import styles from "./styles.scss";
import PropTypes from 'prop-types';
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -34,28 +34,16 @@ export default class List extends Component {
tableRestActionDialogInit(this); tableRestActionDialogInit(this);
} }
@withAsyncErrorHandler static propTypes = {
async fetchPermissions() { permissions: PropTypes.object
const result = await checkPermissions({
createCampaign: {
entityTypeId: 'namespace',
requiredOperations: ['createCampaign']
}
});
this.setState({
createPermitted: result.data.createCampaign
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createCampaign;
const columns = [ const columns = [
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code>, className: styles.tblCol_id }, { data: 2, title: t('id'), render: data => <code>{data}</code>, className: styles.tblCol_id },
@ -153,7 +141,7 @@ export default class List extends Component {
<div> <div>
{tableRestActionDialogRender(this)} {tableRestActionDialogRender(this)}
<Toolbar> <Toolbar>
{this.state.createPermitted && {createPermitted &&
<ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" label={t('createCampaign')}> <ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" label={t('createCampaign')}>
<DropdownLink to="/campaigns/create-regular">{t('regular')}</DropdownLink> <DropdownLink to="/campaigns/create-regular">{t('regular')}</DropdownLink>
<DropdownLink to="/campaigns/create-rss">{t('rss')}</DropdownLink> <DropdownLink to="/campaigns/create-rss">{t('rss')}</DropdownLink>

View file

@ -73,7 +73,7 @@ export default class Statistics extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const entity = this.state.entity; const entity = this.state.entity;
const total = entity.subscriptionsToSend === undefined ? undefined : entity.subscriptionsToSend + entity.delivered; const total = entity.total;
const renderMetrics = (key, label, showZoomIn = true) => { const renderMetrics = (key, label, showZoomIn = true) => {
const val = entity[key] const val = entity[key]

View file

@ -13,10 +13,11 @@ import axios from "../lib/axios";
import {getPublicUrl, getUrl} from "../lib/urls"; import {getPublicUrl, getUrl} from "../lib/urls";
import interoperableErrors from '../../../shared/interoperable-errors'; import interoperableErrors from '../../../shared/interoperable-errors';
import {CampaignStatus, CampaignType} from "../../../shared/campaigns"; import {CampaignStatus, CampaignType} from "../../../shared/campaigns";
import moment from 'moment'; import moment from 'moment-timezone';
import campaignsStyles from "./styles.scss"; import campaignsStyles from "./styles.scss";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
import styles from "../lib/styles.scss";
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -25,7 +26,7 @@ import {withComponentMixins} from "../lib/decorator-helpers";
withPageHelpers, withPageHelpers,
requiresAuthenticatedUser requiresAuthenticatedUser
]) ])
class TestUser extends Component { class PreviewForTestUserModalDialog extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.initForm({ this.initForm({
@ -34,7 +35,9 @@ class TestUser extends Component {
} }
static propTypes = { static propTypes = {
entity: PropTypes.object.isRequired visible: PropTypes.bool.isRequired,
onHide: PropTypes.func.isRequired,
entity: PropTypes.object.isRequired,
} }
localValidateFormValues(state) { localValidateFormValues(state) {
@ -64,6 +67,10 @@ class TestUser extends Component {
} }
} }
async hideModal() {
this.props.onHide();
}
render() { render() {
const t = this.props.t; const t = this.props.t;
@ -76,12 +83,14 @@ class TestUser extends Component {
]; ];
return ( return (
<ModalDialog hidden={!this.props.visible} title={t('Preview Campaign')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('preview'), className: 'btn-primary', onClickAsync: ::this.previewAsync },
{ label: t('close'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}>
<Form stateOwner={this}> <Form stateOwner={this}>
<TableSelect id="testUser" label={t('previewCampaignAs')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.entity.id}`} columns={testUsersColumns} selectionLabelIndex={1} /> <TableSelect id="testUser" label={t('Preview as')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.entity.id}`} columns={testUsersColumns} selectionLabelIndex={1} />
<ButtonRow>
<Button className="btn-primary" label={t('preview')} onClickAsync={::this.previewAsync}/>
</ButtonRow>
</Form> </Form>
</ModalDialog>
); );
} }
} }
@ -96,9 +105,17 @@ class TestUser extends Component {
class SendControls extends Component { class SendControls extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {
showTestSendModal: false,
previewForTestUserVisible: false
};
this.initForm({ this.initForm({
leaveConfirmation: false leaveConfirmation: false
}); });
this.timezoneOptions = moment.tz.names().map(x => [x]);
} }
static propTypes = { static propTypes = {
@ -111,6 +128,7 @@ class SendControls extends Component {
state.setIn(['date', 'error'], null); state.setIn(['date', 'error'], null);
state.setIn(['time', 'error'], null); state.setIn(['time', 'error'], null);
state.setIn(['timezone', 'error'], null);
if (state.getIn(['sendLater', 'value'])) { if (state.getIn(['sendLater', 'value'])) {
const dateValue = state.getIn(['date', 'value']).trim(); const dateValue = state.getIn(['date', 'value']).trim();
@ -126,36 +144,54 @@ class SendControls extends Component {
} else if (!moment(timeValue, 'HH:mm', true).isValid()) { } else if (!moment(timeValue, 'HH:mm', true).isValid()) {
state.setIn(['time', 'error'], t('timeIsInvalid')); state.setIn(['time', 'error'], t('timeIsInvalid'));
} }
const timezone = state.getIn(['timezone', 'value']);
if (!timezone) {
state.setIn(['timezone', 'error'], t('Timezone must be selected'));
}
} }
} }
componentDidMount() { populateSendLater() {
const entity = this.props.entity; const entity = this.props.entity;
if (entity.scheduled) { if (entity.scheduled) {
const date = moment(entity.scheduled); const timezone = entity.data.timezone || moment.tz.guess();
const date = moment.tz(entity.scheduled, timezone);
this.populateFormValues({ this.populateFormValues({
sendLater: true, sendLater: true,
date: date.format('YYYY-MM-DD'), date: date.format('YYYY-MM-DD'),
time: date.format('HH:mm') time: date.format('HH:mm'),
timezone
}); });
} else { } else {
this.populateFormValues({ this.populateFormValues({
sendLater: false, sendLater: false,
date: '', date: '',
time: '' time: '',
timezone: moment.tz.guess()
}); });
} }
} }
componentDidMount() {
this.populateSendLater();
}
componentDidUpdate(prevProps) {
if (prevProps.entity.scheduled !== this.props.entity.scheduled) {
this.populateSendLater();
}
}
async refreshEntity() { async refreshEntity() {
await this.props.refreshEntity(); await this.props.refreshEntity();
} }
async postAndMaskStateError(url) { async postAndMaskStateError(url, data) {
try { try {
await axios.post(getUrl(url)); await axios.post(getUrl(url), data);
} catch (err) { } catch (err) {
if (err instanceof interoperableErrors.InvalidStateError) { if (err instanceof interoperableErrors.InvalidStateError) {
// Just mask the fact that it's not possible to start anything and refresh instead. // Just mask the fact that it's not possible to start anything and refresh instead.
@ -168,17 +204,12 @@ class SendControls extends Component {
async scheduleAsync() { async scheduleAsync() {
if (this.isFormWithoutErrors()) { if (this.isFormWithoutErrors()) {
const data = this.getFormValues(); const data = this.getFormValues();
const date = moment(data.date, 'YYYY-MM-DD'); const dateTime = moment.tz(data.date + ' ' + data.time, 'YYYY-MM-DD HH:mm', data.timezone);
const time = moment(data.time, 'HH:mm');
date.hour(time.hour()); await this.postAndMaskStateError(`rest/campaign-start-at/${this.props.entity.id}`, {
date.minute(time.minute()); startAt: dateTime.valueOf(),
date.second(0); timezone: data.timezone
date.millisecond(0); });
date.utcOffset(0, true); // TODO, process offset from user settings
await this.postAndMaskStateError(`rest/campaign-start-at/${this.props.entity.id}/${date.valueOf()}`);
} else { } else {
this.showFormValidation(); this.showFormValidation();
@ -204,7 +235,17 @@ class SendControls extends Component {
t('doYouWantToLaunchTheCampaign?'), t('doYouWantToLaunchTheCampaign?'),
async () => { async () => {
await this.startAsync(); await this.startAsync();
await this.refreshEntity(); }
);
}
async confirmSchedule() {
const t = this.props.t;
this.actionDialog(
t('confirmLaunch'),
t('Do you want to schedule the campaign for launch?'),
async () => {
await this.scheduleAsync();
} }
); );
} }
@ -257,23 +298,61 @@ class SendControls extends Component {
const t = this.props.t; const t = this.props.t;
const entity = this.props.entity; const entity = this.props.entity;
const yesNoDialog = ( const dialogs = (
<>
<TestSendModalDialog
mode={TestSendModalDialogMode.CAMPAIGN_STATUS}
visible={this.state.showTestSendModal}
onHide={() => this.setState({showTestSendModal: false})}
campaign={this.props.entity}
/>
<PreviewForTestUserModalDialog
visible={this.state.previewForTestUserVisible}
onHide={() => this.setState({previewForTestUserVisible: false})}
entity={this.props.entity}
/>
<ModalDialog hidden={!this.state.modalVisible} title={this.state.modalTitle} onCloseAsync={() => this.modalAction(false)} buttons={[ <ModalDialog hidden={!this.state.modalVisible} title={this.state.modalTitle} onCloseAsync={() => this.modalAction(false)} buttons={[
{ label: t('no'), className: 'btn-primary', onClickAsync: () => this.modalAction(false) }, { label: t('no'), className: 'btn-primary', onClickAsync: () => this.modalAction(false) },
{ label: t('yes'), className: 'btn-danger', onClickAsync: () => this.modalAction(true) } { label: t('yes'), className: 'btn-danger', onClickAsync: () => this.modalAction(true) }
]}> ]}>
{this.state.modalMessage} {this.state.modalMessage}
</ModalDialog> </ModalDialog>
</>
);
const testButtons = (
<>
<Button className="btn-success" label={t('Preview')} onClickAsync={async () => this.setState({previewForTestUserVisible: true})}/>
<Button className="btn-success" label={t('Test send')} onClickAsync={async () => this.setState({showTestSendModal: true})}/>
</>
); );
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) { if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
const subscrInfo = entity.subscriptionsToSend === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`; const timezoneColumns = [
{ data: 0, title: t('Timezone') }
];
const dateValue = (this.getFormValue('date') || '').trim();
const timeValue = (this.getFormValue('time') || '').trim();
const timezone = this.getFormValue('timezone');
let dateTimeHelp = t('Select date, time and a timezone to display the date and time with offset');
let dateTimeAlert = null;
if (moment(dateValue, 'YYYY-MM-DD', true).isValid() && moment(timeValue, 'HH:mm', true).isValid() && timezone) {
const dateTime = moment.tz(dateValue + ' ' + timeValue, 'YYYY-MM-DD HH:mm', timezone);
dateTimeHelp = dateTime.toString();
if (!moment().isBefore(dateTime)) {
dateTimeAlert = <div className="alert alert-danger" role="alert">{t('Scheduled date/time seems to be in the past. If you schedule the send, campaign will be sent immediately.')}</div>;
}
}
return ( return (
<div>{yesNoDialog} <div>{dialogs}
<AlignedRow label={t('sendStatus')}> <AlignedRow label={t('sendStatus')}>
{entity.scheduled ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')} {entity.status === CampaignStatus.SCHEDULED ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
</AlignedRow> </AlignedRow>
<Form stateOwner={this}> <Form stateOwner={this}>
@ -282,75 +361,97 @@ class SendControls extends Component {
<div> <div>
<DatePicker id="date" label={t('date')} /> <DatePicker id="date" label={t('date')} />
<InputField id="time" label={t('time')} help={t('enter24HourTimeInFormatHhmmEg1348')}/> <InputField id="time" label={t('time')} help={t('enter24HourTimeInFormatHhmmEg1348')}/>
{/* TODO: Timezone selector */} <TableSelect id="timezone" label={t('Timezone')} dropdown columns={timezoneColumns} selectionKeyIndex={0} selectionLabelIndex={0} data={this.timezoneOptions}
help={dateTimeHelp}
/>
{dateTimeAlert && <AlignedRow>{dateTimeAlert}</AlignedRow>}
</div> </div>
} }
</Form> </Form>
<ButtonRow className={campaignsStyles.sendButtonRow}> <ButtonRow className={campaignsStyles.sendButtonRow}>
{this.getFormValue('sendLater') ? {this.getFormValue('sendLater') ?
<Button className="btn-primary" icon="send" label={(entity.scheduled ? t('rescheduleSend') : t('scheduleSend')) + subscrInfo} onClickAsync={::this.scheduleAsync}/> <Button className="btn-primary" icon="play" label={entity.status === CampaignStatus.SCHEDULED ? t('rescheduleSend') : t('scheduleSend')} onClickAsync={::this.confirmSchedule}/>
: :
<Button className="btn-primary" icon="send" label={t('send') + subscrInfo} onClickAsync={::this.confirmStart}/> <Button className="btn-primary" icon="play" label={t('send')} onClickAsync={::this.confirmStart}/>
} }
{entity.status === CampaignStatus.SCHEDULED && <Button className="btn-primary" icon="pause" label={t('Pause')} onClickAsync={::this.stopAsync}/>}
{entity.status === CampaignStatus.PAUSED && <Button className="btn-primary" icon="redo" label={t('reset')} onClickAsync={::this.resetAsync}/>}
{entity.status === CampaignStatus.PAUSED && <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>} {entity.status === CampaignStatus.PAUSED && <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>}
{testButtons}
</ButtonRow>
</div>
);
} else if (entity.status === CampaignStatus.PAUSING) {
return (
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('Campaign is being paused. Please wait.')}
</AlignedRow>
<ButtonRow>
<Button className="btn-primary" icon="pause" label={t('Pausing')} disabled={true}/>
<LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>
{testButtons}
</ButtonRow> </ButtonRow>
</div> </div>
); );
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) { } else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
return ( return (
<div>{yesNoDialog} <div>{dialogs}
<AlignedRow label={t('sendStatus')}> <AlignedRow label={t('sendStatus')}>
{t('campaignIsBeingSentOut')} {t('campaignIsBeingSentOut')}
</AlignedRow> </AlignedRow>
<ButtonRow> <ButtonRow>
<Button className="btn-primary" icon="stop" label={t('stop')} onClickAsync={::this.stopAsync}/> <Button className="btn-primary" icon="pause" label={t('Pause')} onClickAsync={::this.stopAsync}/>
<LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/> <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>
{testButtons}
</ButtonRow> </ButtonRow>
</div> </div>
); );
} else if (entity.status === CampaignStatus.FINISHED) { } else if (entity.status === CampaignStatus.FINISHED) {
const subscrInfo = entity.subscriptionsToSend === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return ( return (
<div>{yesNoDialog} <div>{dialogs}
<AlignedRow label={t('sendStatus')}> <AlignedRow label={t('sendStatus')}>
{t('allMessagesSent!HitContinueIfYouYouWant')} {t('allMessagesSent!HitContinueIfYouYouWant')}
</AlignedRow> </AlignedRow>
<ButtonRow> <ButtonRow>
<Button className="btn-primary" icon="play" label={t('continue') + subscrInfo} onClickAsync={::this.confirmStart}/> <Button className="btn-primary" icon="play" label={t('continue')} onClickAsync={::this.confirmStart}/>
<Button className="btn-primary" icon="refresh" label={t('reset')} onClickAsync={::this.resetAsync}/> <Button className="btn-primary" icon="redo" label={t('reset')} onClickAsync={::this.resetAsync}/>
<LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/> <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>
{testButtons}
</ButtonRow> </ButtonRow>
</div> </div>
); );
} else if (entity.status === CampaignStatus.INACTIVE) { } else if (entity.status === CampaignStatus.INACTIVE) {
return ( return (
<div>{yesNoDialog} <div>{dialogs}
<AlignedRow label={t('sendStatus')}> <AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsCurrentlyDisabledClick')} {t('yourCampaignIsCurrentlyDisabledClick')}
</AlignedRow> </AlignedRow>
<ButtonRow> <ButtonRow>
<Button className="btn-primary" icon="play" label={t('enable')} onClickAsync={::this.enableAsync}/> <Button className="btn-primary" icon="play" label={t('enable')} onClickAsync={::this.enableAsync}/>
{testButtons}
</ButtonRow> </ButtonRow>
</div> </div>
); );
} else if (entity.status === CampaignStatus.ACTIVE) { } else if (entity.status === CampaignStatus.ACTIVE) {
return ( return (
<div>{yesNoDialog} <div>{dialogs}
<AlignedRow label={t('sendStatus')}> <AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsEnabledAndSendingMessages')} {t('yourCampaignIsEnabledAndSendingMessages')}
</AlignedRow> </AlignedRow>
<ButtonRow> <ButtonRow>
<Button className="btn-primary" icon="stop" label={t('disable')} onClickAsync={::this.disableAsync}/> <Button className="btn-primary" icon="stop" label={t('disable')} onClickAsync={::this.disableAsync}/>
{testButtons}
</ButtonRow> </ButtonRow>
</div> </div>
); );
} else {
} else {
return null; return null;
} }
} }
@ -441,7 +542,7 @@ export default class Status extends Component {
addOverridable('from_name', t('fromName')); addOverridable('from_name', t('fromName'));
addOverridable('from_email', t('fromEmailAddress')); addOverridable('from_email', t('fromEmailAddress'));
addOverridable('reply_to', t('replytoEmailAddress')); addOverridable('reply_to', t('replytoEmailAddress'));
addOverridable('subject', t('subjectLine')); sendSettings.push(<AlignedRow key="subject" label={t('subjectLine')}>{entity.subject}</AlignedRow>);
} else { } else {
sendSettings = <AlignedRow>{t('loadingSendConfiguration')}</AlignedRow> sendSettings = <AlignedRow>{t('loadingSendConfiguration')}</AlignedRow>
} }
@ -491,13 +592,6 @@ export default class Status extends Component {
<Table withHeader dataUrl={`rest/lists-with-segment-by-campaign-table/${this.props.entity.id}`} columns={listsColumns} /> <Table withHeader dataUrl={`rest/lists-with-segment-by-campaign-table/${this.props.entity.id}`} columns={listsColumns} />
</AlignedRow> </AlignedRow>
{(entity.type === CampaignType.REGULAR || entity.type === CampaignType.TRIGGERED) &&
<div>
<hr/>
<TestUser entity={entity}/>
</div>
}
<hr/> <hr/>
<SendControls entity={entity} refreshEntity={::this.refreshEntity}/> <SendControls entity={entity} refreshEntity={::this.refreshEntity}/>

View file

@ -5,13 +5,26 @@ import {withTranslation} from '../lib/i18n';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {ModalDialog} from "../lib/bootstrap-components"; import {ModalDialog} from "../lib/bootstrap-components";
import {requiresAuthenticatedUser, withPageHelpers} from "../lib/page"; import {requiresAuthenticatedUser, withPageHelpers} from "../lib/page";
import {Form, TableSelect, withForm} from "../lib/form"; import {CheckBox, Dropdown, Form, InputField, TableSelect, withForm} from "../lib/form";
import {withErrorHandling} from "../lib/error-handling"; import {withErrorHandling} from "../lib/error-handling";
import {getMailerTypes} from "../send-configurations/helpers"; import {getMailerTypes} from "../send-configurations/helpers";
import axios from '../lib/axios'; import axios from '../lib/axios';
import {getUrl} from '../lib/urls'; import {getUrl} from '../lib/urls';
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import {CampaignType} from "../../../shared/campaigns";
const Target = {
CAMPAIGN_ONE: 'campaign_one',
CAMPAIGN_ALL: 'campaign_all',
LIST_ONE: 'list_one',
LIST_ALL: 'list_all'
};
export const TestSendModalDialogMode = {
TEMPLATE: 0,
CAMPAIGN_CONTENT: 1,
CAMPAIGN_STATUS: 2
}
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -27,21 +40,39 @@ export class TestSendModalDialog extends Component {
this.mailerTypes = getMailerTypes(props.t); this.mailerTypes = getMailerTypes(props.t);
this.initForm({ this.initForm({
leaveConfirmation: false leaveConfirmation: false,
onChangeBeforeValidation: {
list: this.onListChanged
}
}); });
} }
static propTypes = { static propTypes = {
stateOwner: PropTypes.object,
visible: PropTypes.bool.isRequired, visible: PropTypes.bool.isRequired,
mode: PropTypes.number.isRequired,
onHide: PropTypes.func.isRequired, onHide: PropTypes.func.isRequired,
getDataAsync: PropTypes.func.isRequired, getDataAsync: PropTypes.func,
entity: PropTypes.object campaign: PropTypes.object
}
onListChanged(mutStateData, key, oldValue, newValue) {
mutStateData.setIn(['segment', 'value'], null);
} }
componentDidMount() { componentDidMount() {
const t = this.props.t;
this.populateFormValues({ this.populateFormValues({
testUser: null, target: Target.CAMPAIGN_ONE,
testUserSubscriptionCid: null,
testUserListAndSubscriptionCid: null,
subjectPrepend: '',
subjectAppend: t(' [Test]'),
sendConfiguration: null,
listCid: null,
list: null,
segment: null,
useSegmentation: false
}); });
} }
@ -52,25 +83,50 @@ export class TestSendModalDialog extends Component {
async performAction() { async performAction() {
const props = this.props; const props = this.props;
const t = props.t; const t = props.t;
const mode = this.props.mode;
if (this.isFormWithoutErrors()) { if (this.isFormWithoutErrors()) {
try { try {
this.hideFormValidation(); this.hideFormValidation();
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('sendingTestEmail')); this.setFormStatusMessage('info', t('sendingTestEmail'));
const data = await this.props.getDataAsync(); const data = {};
const campaignCid = props.entity.cid; if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.TEMPLATE) {
const [listCid, subscriptionCid] = this.getFormValue('testUser').split(':'); const contentData = await this.props.getDataAsync();
data.html = contentData.html;
data.text = contentData.text;
data.tagLanguage = contentData.tagLanguage;
}
if (mode === TestSendModalDialogMode.TEMPLATE) {
data.listCid = this.getFormValue('listCid');
data.subscriptionCid = this.getFormValue('testUserSubscriptionCid');
data.sendConfigurationId = this.getFormValue('sendConfiguration');
} else if (mode === TestSendModalDialogMode.CAMPAIGN_STATUS || mode === TestSendModalDialogMode.CAMPAIGN_CONTENT) {
data.campaignId = props.campaign.id;
data.subjectPrepend = this.getFormValue('subjectPrepend');
data.subjectAppend = this.getFormValue('subjectAppend');
const target = this.getFormValue('target');
if (target === Target.CAMPAIGN_ONE) {
const [listCid, subscriptionCid] = this.getFormValue('testUserListAndSubscriptionCid').split(':');
data.listCid = listCid; data.listCid = listCid;
data.subscriptionCid = subscriptionCid; data.subscriptionCid = subscriptionCid;
data.sendConfigurationId = props.entity.send_configuration;
data.campaignId = props.entity.id;
await axios.post(getUrl('rest/template-test-send'), data); } else if (target === Target.LIST_ALL) {
data.listId = this.getFormValue('list');
data.segmentId = this.getFormValue('useSegmentation') ? this.getFormValue('segment') : null;
} else if (target === Target.LIST_ONE) {
data.listCid = this.getFormValue('listCid');
data.subscriptionCid = this.getFormValue('testUserSubscriptionCid');
}
}
await axios.post(getUrl('rest/campaign-test-send'), data);
this.clearFormStatusMessage(); this.clearFormStatusMessage();
@ -87,17 +143,119 @@ export class TestSendModalDialog extends Component {
localValidateFormValues(state) { localValidateFormValues(state) {
const t = this.props.t; const t = this.props.t;
const props = this.props;
const target = this.getFormValue('target');
const mode = this.props.mode;
if (!state.getIn(['testUser', 'value'])) { state.setIn(['listCid', 'error'], null);
state.setIn(['testUser', 'error'], t('subscriptionHasToBeSelected')) state.setIn(['sendConfiguration', 'error'], null);
} else { state.setIn(['testUserSubscriptionCid', 'error'], null);
state.setIn(['testUser', 'error'], null); state.setIn(['testUserListAndSubscriptionCid', 'error'], null);
state.setIn(['list', 'error'], null);
state.setIn(['segment', 'error'], null);
if (mode === TestSendModalDialogMode.TEMPLATE) {
if (!state.getIn(['listCid', 'value'])) {
state.setIn(['listCid', 'error'], t('listHasToBeSelected'))
}
if (!state.getIn(['sendConfiguration', 'value'])) {
state.setIn(['sendConfiguration', 'error'], t('sendConfigurationHasToBeSelected'))
}
if (!state.getIn(['testUserSubscriptionCid', 'value'])) {
state.setIn(['testUserSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.CAMPAIGN_ONE) {
if (!state.getIn(['testUserListAndSubscriptionCid', 'value'])) {
state.setIn(['testUserListAndSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ONE) {
if (!state.getIn(['listCid', 'value'])) {
state.setIn(['listCid', 'error'], t('listHasToBeSelected'))
}
if (!state.getIn(['testUserSubscriptionCid', 'value'])) {
state.setIn(['testUserSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ALL) {
if (!state.getIn(['list', 'value'])) {
state.setIn(['list', 'error'], t('listMustBeSelected'));
}
if (state.getIn(['useSegmentation', 'value']) && !state.getIn(['segment', 'value'])) {
state.setIn(['segment', 'error'], t('segmentMustBeSelected'));
}
} }
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const props = this.props;
const content = [];
const target = this.getFormValue('target');
const mode = this.props.mode;
if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) {
const targetOpts = [
{key: Target.CAMPAIGN_ONE, label: t('Single test user of the campaign')},
{key: Target.CAMPAIGN_ALL, label: t('All test users of the campaign')},
{key: Target.LIST_ONE, label: t('Single test user from a list')},
{key: Target.LIST_ALL, label: t('All test users from a list/segment')}
];
content.push(
<Dropdown key="target" id="target" format="wide" label={t('Select to where you want to send the test')} options={targetOpts}/>
);
}
if (mode === TestSendModalDialogMode.TEMPLATE) {
const listCid = this.getFormValue('listCid');
const testUsersColumns = [
{ data: 1, title: t('subscriptionId'), 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: 6, title: t('namespace') }
];
content.push(
<TableSelect key="sendConfiguration" id="sendConfiguration" format="wide" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-with-send-permission-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
);
content.push(
<TableSelect key="listCid" id="listCid" format="wide" label={t('list')} withHeader dropdown dataUrl={`rest/lists-table`} columns={listsColumns} selectionKeyIndex={2} selectionLabelIndex={1} />
);
if (listCid) {
content.push(
<TableSelect key="testUserSubscriptionCid" id="testUserSubscriptionCid" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/subscriptions-test-user-table/${listCid}`} columns={testUsersColumns} selectionKeyIndex={1} selectionLabelIndex={2} />
);
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.CAMPAIGN_ONE) {
const testUsersColumns = [ const testUsersColumns = [
{data: 1, title: t('email')}, {data: 1, title: t('email')},
{data: 2, title: t('subscriptionId'), render: data => <code>{data}</code>}, {data: 2, title: t('subscriptionId'), render: data => <code>{data}</code>},
@ -106,13 +264,83 @@ export class TestSendModalDialog extends Component {
{data: 5, title: t('listNamespace')} {data: 5, title: t('listNamespace')}
]; ];
content.push(
<TableSelect key="testUserListAndSubscriptionCid" id="testUserListAndSubscriptionCid" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.campaign.id}`} columns={testUsersColumns} selectionLabelIndex={1} />
);
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ONE) {
const listCid = this.getFormValue('listCid');
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 testUsersColumns = [
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 2, title: t('email') }
];
content.push(
<TableSelect key="listCid" id="listCid" format="wide" label={t('list')} withHeader dropdown dataUrl={`rest/lists-table`} columns={listsColumns} selectionKeyIndex={2} selectionLabelIndex={1} />
);
if (listCid) {
content.push(
<TableSelect key="testUserSubscriptionCid" id="testUserSubscriptionCid" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/subscriptions-test-user-table/${listCid}`} columns={testUsersColumns} selectionKeyIndex={1} selectionLabelIndex={2} />
);
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ALL) {
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 segmentsColumns = [
{ data: 1, title: t('name') }
];
content.push(
<TableSelect key="list" id="list" format="wide" label={t('list')} withHeader dropdown dataUrl='rest/lists-table' columns={listsColumns} selectionLabelIndex={1} />
);
const selectedList = this.getFormValue('list');
content.push(
<div key="segment">
<CheckBox id="useSegmentation" format="wide" text={t('useAParticularSegment')}/>
{selectedList && this.getFormValue('useSegmentation') &&
<TableSelect id="segment" format="wide" withHeader dropdown dataUrl={`rest/segments-table/${selectedList}`} columns={segmentsColumns} selectionLabelIndex={1} />
}
</div>
);
}
if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) {
content.push(
<InputField key="subjectPrepend" id="subjectPrepend" format="wide" label={t('Prepend to subject')}/>
);
content.push(
<InputField key="subjectAppend" id="subjectAppend" format="wide" label={t('Append to subject')}/>
);
}
return ( return (
<ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[ <ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction }, { label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
{ label: t('cancel'), className: 'btn-danger', onClickAsync: ::this.hideModal } { label: t('close'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}> ]}>
<Form stateOwner={this} format="wide"> <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} /> {content}
</Form> </Form>
</ModalDialog> </ModalDialog>
); );

View file

@ -18,7 +18,8 @@ export function getCampaignLabels(t) {
[CampaignStatus.PAUSED]: t('paused'), [CampaignStatus.PAUSED]: t('paused'),
[CampaignStatus.INACTIVE]: t('inactive'), [CampaignStatus.INACTIVE]: t('inactive'),
[CampaignStatus.ACTIVE]: t('active'), [CampaignStatus.ACTIVE]: t('active'),
[CampaignStatus.SENDING]: t('sending') [CampaignStatus.SENDING]: t('sending'),
[CampaignStatus.PAUSING]: t('Pausing')
}; };

View file

@ -17,6 +17,7 @@ import {SubscriptionStatus} from "../../../shared/lists";
import StatisticsOpened from "./StatisticsOpened"; import StatisticsOpened from "./StatisticsOpened";
import StatisticsLinkClicks from "./StatisticsLinkClicks"; import StatisticsLinkClicks from "./StatisticsLinkClicks";
import {ellipsizeBreadcrumbLabel} from "../lib/helpers" import {ellipsizeBreadcrumbLabel} from "../lib/helpers"
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) { function getMenus(t) {
const aggLabels = { const aggLabels = {
@ -28,7 +29,14 @@ function getMenus(t) {
'campaigns': { 'campaigns': {
title: t('campaigns'), title: t('campaigns'),
link: '/campaigns', link: '/campaigns',
panelComponent: CampaignsList, checkPermissions: {
createCampaign: {
entityTypeId: 'namespace',
requiredOperations: ['createCampaign']
},
...namespaceCheckPermissions('createCampaign')
},
panelRender: props => <CampaignsList permissions={props.permissions}/>,
children: { children: {
':campaignId([0-9]+)': { ':campaignId([0-9]+)': {
title: resolved => t('campaignName', {name: ellipsizeBreadcrumbLabel(resolved.campaign.name)}), title: resolved => t('campaignName', {name: ellipsizeBreadcrumbLabel(resolved.campaign.name)}),
@ -94,7 +102,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/campaigns/${params.campaignId}/edit`, link: params => `/campaigns/${params.campaignId}/edit`,
visible: resolved => resolved.campaign.permissions.includes('edit'), visible: resolved => resolved.campaign.permissions.includes('edit'),
panelRender: props => <CampaignsCUD action={props.match.params.action} entity={props.resolved.campaign} /> panelRender: props => <CampaignsCUD action={props.match.params.action} entity={props.resolved.campaign} permissions={props.permissions} />
}, },
content: { content: {
title: t('content'), title: t('content'),
@ -153,15 +161,15 @@ function getMenus(t) {
}, },
'create-regular': { 'create-regular': {
title: t('createRegularCampaign'), title: t('createRegularCampaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.REGULAR} /> panelRender: props => <CampaignsCUD action="create" type={CampaignType.REGULAR} permissions={props.permissions} />
}, },
'create-rss': { 'create-rss': {
title: t('createRssCampaign'), title: t('createRssCampaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.RSS} /> panelRender: props => <CampaignsCUD action="create" type={CampaignType.RSS} permissions={props.permissions} />
}, },
'create-triggered': { 'create-triggered': {
title: t('createTriggeredCampaign'), title: t('createTriggeredCampaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.TRIGGERED} /> panelRender: props => <CampaignsCUD action="create" type={CampaignType.TRIGGERED} permissions={props.permissions} />
} }
} }
} }

View file

@ -2,17 +2,19 @@
import React from "react"; import React from "react";
export function createComponentMixin(contexts, deps, decoratorFn) { export function createComponentMixin(opts) {
return { return {
contexts, contexts: opts.contexts || [],
deps, deps: opts.deps || [],
decoratorFn delegateFuns: opts.delegateFuns || [],
decoratorFn: opts.decoratorFn
}; };
} }
export function withComponentMixins(mixins, delegateFuns) { export function withComponentMixins(mixins, delegateFuns) {
const mixinsClosure = new Set(); const mixinsClosure = new Set();
for (const mixin of mixins) { for (const mixin of mixins) {
console.assert(mixin);
mixinsClosure.add(mixin); mixinsClosure.add(mixin);
for (const dep of mixin.deps) { for (const dep of mixin.deps) {
mixinsClosure.add(dep); mixinsClosure.add(dep);
@ -34,6 +36,10 @@ export function withComponentMixins(mixins, delegateFuns) {
mixinDelegateFuns.push(...delegateFuns); mixinDelegateFuns.push(...delegateFuns);
} }
for (const mixin of mixinsClosure.values()) {
mixinDelegateFuns.push(...mixin.delegateFuns);
}
function TargetClassWithCtors(props) { function TargetClassWithCtors(props) {
if (!new.target) { if (!new.target) {
throw new TypeError(); throw new TypeError();
@ -55,7 +61,25 @@ export function withComponentMixins(mixins, delegateFuns) {
TargetClassWithCtors[attr] = TargetClass[attr]; TargetClassWithCtors[attr] = TargetClass[attr];
} }
function incorporateMixins(DecoratedInner) {
for (const mixin of mixinsClosure.values()) {
if (mixin.decoratorFn) {
const res = mixin.decoratorFn(DecoratedInner, TargetClassWithCtors);
if (res.cls) {
DecoratedInner = res.cls;
}
if (res.ctor) {
ctors.push(res.ctor);
}
}
}
return DecoratedInner;
}
if (mixinDelegateFuns.length > 0) {
class ComponentMixinsInner extends React.Component { class ComponentMixinsInner extends React.Component {
render() { render() {
const props = { const props = {
@ -70,23 +94,7 @@ export function withComponentMixins(mixins, delegateFuns) {
} }
} }
let DecoratedInner = ComponentMixinsInner; const DecoratedInner = incorporateMixins(ComponentMixinsInner);
for (const mixin of mixinsClosure.values()) {
const res = mixin.decoratorFn(DecoratedInner, TargetClassWithCtors);
if (res.cls) {
DecoratedInner = res.cls;
}
if (res.ctor) {
ctors.push(res.ctor);
}
if (res.delegateFuns) {
mixinDelegateFuns.push(...res.delegateFuns);
}
}
class ComponentMixinsOuter extends React.Component { class ComponentMixinsOuter extends React.Component {
constructor(props) { constructor(props) {
@ -102,7 +110,7 @@ export function withComponentMixins(mixins, delegateFuns) {
}; };
return <DecoratedInner {...props}/> return <DecoratedInner {...props}/>
} };
for (const [propName, Context] of contexts.entries()) { for (const [propName, Context] of contexts.entries()) {
const existingInnerFn = innerFn; const existingInnerFn = innerFn;
@ -129,6 +137,35 @@ export function withComponentMixins(mixins, delegateFuns) {
} }
return ComponentMixinsOuter; return ComponentMixinsOuter;
} else {
const DecoratedInner = incorporateMixins(TargetClassWithCtors);
function ComponentContextProvider(props) {
let innerFn = props => {
return <DecoratedInner {...props}/>
};
for (const [propName, Context] of contexts.entries()) {
const existingInnerFn = innerFn;
innerFn = parentProps => (
<Context.Consumer>
{
value => existingInnerFn({
...parentProps,
[propName]: value
})
}
</Context.Consumer>
);
}
return innerFn(props);
}
return ComponentContextProvider;
}
}; };
} }

View file

@ -21,7 +21,9 @@ function handleError(that, error) {
} }
export const ParentErrorHandlerContext = React.createContext(null); export const ParentErrorHandlerContext = React.createContext(null);
export const withErrorHandling = createComponentMixin([{context: ParentErrorHandlerContext, propName: 'parentErrorHandler'}], [], (TargetClass, InnerClass) => { export const withErrorHandling = createComponentMixin({
contexts: [{context: ParentErrorHandlerContext, propName: 'parentErrorHandler'}],
decoratorFn: (TargetClass, InnerClass) => {
/* Example of use: /* Example of use:
this.getFormValuesFromURL(....).catch(error => this.handleError(error)); this.getFormValuesFromURL(....).catch(error => this.handleError(error));
@ -48,6 +50,7 @@ export const withErrorHandling = createComponentMixin([{context: ParentErrorHand
}; };
return {}; return {};
}
}); });
export function withAsyncErrorHandler(target, name, descriptor) { export function withAsyncErrorHandler(target, name, descriptor) {

View file

@ -12,7 +12,6 @@ import {TreeSelectMode, TreeTable} from './tree';
import {Table, TableSelectMode} from './table'; import {Table, TableSelectMode} from './table';
import {Button} from "./bootstrap-components"; import {Button} from "./bootstrap-components";
import {SketchPicker} from 'react-color'; import {SketchPicker} from 'react-color';
import deepEqual from "fast-deep-equal";
import ACEEditorRaw from 'react-ace'; import ACEEditorRaw from 'react-ace';
import 'brace/theme/github'; import 'brace/theme/github';
@ -47,12 +46,15 @@ const FormSendMethod = HTTPMethod;
export const FormStateOwnerContext = React.createContext(null); export const FormStateOwnerContext = React.createContext(null);
const withFormStateOwner = createComponentMixin([{context: FormStateOwnerContext, propName: 'formStateOwner'}], [], (TargetClass, InnerClass) => { const withFormStateOwner = createComponentMixin({
contexts: [{context: FormStateOwnerContext, propName: 'formStateOwner'}],
decoratorFn: (TargetClass, InnerClass) => {
InnerClass.prototype.getFormStateOwner = function () { InnerClass.prototype.getFormStateOwner = function () {
return this.props.formStateOwner; return this.props.formStateOwner;
}; };
return {}; return {};
}
}); });
export function withFormErrorHandlers(target, name, descriptor) { export function withFormErrorHandlers(target, name, descriptor) {
@ -333,15 +335,19 @@ class InputField extends Component {
const className = owner.addFormValidationClass('form-control', id); const className = owner.addFormValidationClass('form-control', id);
/* This is for debugging purposes when React reports that InputField is uncontrolled
const value = owner.getFormValue(id);
if (value === null || value === undefined) console.log(`Warning: InputField ${id} is ${value}`);
*/
const value = owner.getFormValue(id);
if (value === null || value === undefined) console.log(`Warning: InputField ${id} is ${value}`);
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<input type={type} value={owner.getFormValue(id)} placeholder={props.placeholder} id={htmlId} className={className} aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/> <input type={type} value={owner.getFormValue(id)} placeholder={props.placeholder} id={htmlId} className={className} aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/>
); );
} }
} }
@withComponentMixins([
withFormStateOwner
])
class CheckBox extends Component { class CheckBox extends Component {
static propTypes = { static propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
@ -353,8 +359,11 @@ class CheckBox extends Component {
} }
render() { render() {
return (
<FormStateOwnerContext.Consumer>
{
owner => {
const props = this.props; const props = this.props;
const owner = this.getFormStateOwner();
const id = this.props.id; const id = this.props.id;
const htmlId = 'form_' + id; const htmlId = 'form_' + id;
@ -367,6 +376,12 @@ class CheckBox extends Component {
</div> </div>
); );
} }
}
</FormStateOwnerContext.Consumer>
);
}
} }
@withComponentMixins([ @withComponentMixins([
@ -483,6 +498,15 @@ class RadioGroup extends Component {
withFormStateOwner withFormStateOwner
]) ])
class TextArea extends Component { class TextArea extends Component {
constructor() {
super();
this.onChange = evt => {
const id = this.props.id;
const owner = this.getFormStateOwner();
owner.updateFormValue(id, evt.target.value);
}
}
static propTypes = { static propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
@ -500,7 +524,7 @@ class TextArea extends Component {
const className = owner.addFormValidationClass('form-control ' + (props.className || '') , id); const className = owner.addFormValidationClass('form-control ' + (props.className || '') , id);
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<textarea id={htmlId} placeholder={props.placeholder} value={owner.getFormValue(id) || ''} className={className} aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}></textarea> <textarea id={htmlId} placeholder={props.placeholder} value={owner.getFormValue(id) || ''} className={className} aria-describedby={htmlId + '_help'} onChange={this.onChange}></textarea>
); );
} }
} }
@ -705,7 +729,8 @@ class Dropdown extends Component {
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
options: PropTypes.array, options: PropTypes.array,
className: PropTypes.string, className: PropTypes.string,
format: PropTypes.string format: PropTypes.string,
disabled: PropTypes.bool
} }
render() { render() {
@ -733,7 +758,7 @@ class Dropdown extends Component {
const className = owner.addFormValidationClass('form-control ' + (props.className || '') , id); const className = owner.addFormValidationClass('form-control ' + (props.className || '') , id);
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<select id={htmlId} className={className} aria-describedby={htmlId + '_help'} value={owner.getFormValue(id)} onChange={evt => owner.updateFormValue(id, evt.target.value)}> <select id={htmlId} className={className} aria-describedby={htmlId + '_help'} value={owner.getFormValue(id)} onChange={evt => owner.updateFormValue(id, evt.target.value)} disabled={props.disabled}>
{options} {options}
</select> </select>
); );
@ -793,7 +818,7 @@ class TreeTableSelect extends Component {
dataUrl: PropTypes.string, dataUrl: PropTypes.string,
data: PropTypes.array, data: PropTypes.array,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
format: PropTypes.string format: PropTypes.string,
} }
async onSelectionChangedAsync(sel) { async onSelectionChangedAsync(sel) {
@ -970,7 +995,8 @@ class ACEEditor extends Component {
} }
const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => { const withForm = createComponentMixin({
decoratorFn: (TargetClass, InnerClass) => {
const proto = InnerClass.prototype; const proto = InnerClass.prototype;
const cleanFormState = Immutable.Map({ const cleanFormState = Immutable.Map({
@ -1044,6 +1070,7 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
axios.post(getUrl(settings.serverValidation.url), payload) axios.post(getUrl(settings.serverValidation.url), payload)
.then(response => { .then(response => {
if (self.isComponentMounted()) {
self.setState(previousState => ({ self.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => { formState: previousState.formState.withMutations(mutState => {
mutState.set('isServerValidationRunning', false); mutState.set('isServerValidationRunning', false);
@ -1062,8 +1089,10 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
})); }));
scheduleValidateForm(self); scheduleValidateForm(self);
}
}) })
.catch(error => { .catch(error => {
if (self.isComponentMounted()) {
console.log('Error in "validateFormState": ' + error); console.log('Error in "validateFormState": ' + error);
self.setState(previousState => ({ self.setState(previousState => ({
@ -1072,6 +1101,7 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
// TODO: It might be good not to give up immediatelly, but retry a couple of times // TODO: It might be good not to give up immediatelly, but retry a couple of times
// scheduleValidateForm(self); // scheduleValidateForm(self);
}
}); });
} else { } else {
if (formValidateResolve) { if (formValidateResolve) {
@ -1089,6 +1119,26 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
} }
} }
const previousComponentDidMount = proto.componentDidMount;
proto.componentDidMount = function () {
this._isComponentMounted = true;
if (previousComponentDidMount) {
previousComponentDidMount.apply(this);
}
};
const previousComponentWillUnmount = proto.componentWillUnmount;
proto.componentWillUnmount = function () {
this._isComponentMounted = false;
if (previousComponentWillUnmount) {
previousComponentDidMount.apply(this);
}
};
proto.isComponentMounted = function () {
return !!this._isComponentMounted;
}
proto.initForm = function (settings) { proto.initForm = function (settings) {
const state = this.state || {}; const state = this.state || {};
state.formState = cleanFormState; state.formState = cleanFormState;
@ -1220,7 +1270,9 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
proto.waitForFormServerValidated = async function () { proto.waitForFormServerValidated = async function () {
if (!this.isFormServerValidated()) { if (!this.isFormServerValidated()) {
await new Promise(resolve => { formValidateResolve = resolve; }); await new Promise(resolve => {
formValidateResolve = resolve;
});
} }
}; };
@ -1341,7 +1393,24 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
const currentData = getSaveData(self, self.state.formState.get('data')); const currentData = getSaveData(self, self.state.formState.get('data'));
const savedData = self.state.formState.get('savedData'); const savedData = self.state.formState.get('savedData');
return !deepEqual(currentData, savedData); function isDifferent(data1, data2, prefix) {
if (typeof data1 === 'object' && typeof data2 === 'object' && data1 && data2) {
const keys = new Set([...Object.keys(data1), ...Object.keys(data2)]);
for (const key of keys) {
if (isDifferent(data1[key], data2[key], `${prefix}/${key}`)) {
return true;
}
}
} else if (data1 !== data2) {
// console.log(prefix);
return true;
}
return false;
}
const result = isDifferent(currentData, savedData, '');
return result;
}; };
proto.isFormChanged = function () { proto.isFormChanged = function () {
@ -1500,6 +1569,7 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
}; };
return {}; return {};
}
}); });
function filterData(obj, allowedKeys) { function filterData(obj, allowedKeys) {

View file

@ -1,8 +1,8 @@
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import {I18nextProvider, withNamespaces} from 'react-i18next';
import i18n from 'i18next'; import i18n from 'i18next';
import {withNamespaces} from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
@ -63,12 +63,30 @@ i18n
export default i18n; export default i18n;
export const withTranslation = createComponentMixin([], [], (TargetClass, InnerClass) => { export const TranslationContext = React.createContext(null);
return {
cls: withNamespaces()(TargetClass) export const withTranslation = createComponentMixin({
}; contexts: [{context: TranslationContext, propName: 't'}]
}); });
const TranslationContextProvider = withNamespaces()(props => {
return (
<TranslationContext.Provider value={props.t}>
{props.children}
</TranslationContext.Provider>
);
});
export function TranslationRoot(props) {
return (
<I18nextProvider i18n={ i18n }>
<TranslationContextProvider>
{props.children}
</TranslationContextProvider>
</I18nextProvider>
);
}
export function tMark(key) { export function tMark(key) {
return key; return key;
} }

View file

@ -89,7 +89,7 @@ export class RestActionModalDialog extends Component {
const t = this.props.t; const t = this.props.t;
return ( return (
<ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={() => this.hideModal(true)} buttons={[ <ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={async () => await this.hideModal(true)} buttons={[
{ label: t('no'), className: 'btn-primary', onClickAsync: async () => await this.hideModal(true) }, { label: t('no'), className: 'btn-primary', onClickAsync: async () => await this.hideModal(true) },
{ label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performAction } { label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performAction }
]}> ]}>
@ -108,22 +108,30 @@ const entityTypeLabels = {
'sendConfiguration': t => t('sendConfiguration'), 'sendConfiguration': t => t('sendConfiguration'),
'report': t => t('report'), 'report': t => t('report'),
'reportTemplate': t => t('reportTemplate'), 'reportTemplate': t => t('reportTemplate'),
'mosaicoTemplate': t => t('mosaicoTemplate') 'mosaicoTemplate': t => t('mosaicoTemplate'),
'user': t => t('User')
}; };
function _getDependencyErrorMessage(err, t, name) { function _getDependencyErrorMessage(err, t, name) {
return ( return (
<div> <div>
{err.data.dependencies.length > 0 ?
<>
<p>{t('cannoteDeleteNameDueToTheFollowing', {name})}</p> <p>{t('cannoteDeleteNameDueToTheFollowing', {name})}</p>
<ul className={styles.errorsList}> <ul className={styles.errorsList}>
{err.data.dependencies.map(dep => {err.data.dependencies.map(dep =>
dep.link ? dep.link ?
<li key={dep.link}><Link to={dep.link}>{entityTypeLabels[dep.entityTypeId](t)}: {dep.name}</Link></li> <li key={dep.link}><Link
to={dep.link}>{entityTypeLabels[dep.entityTypeId](t)}: {dep.name}</Link></li>
: // if no dep.link is present, it means the user has no permission to view the entity, thus only id without the link is shown : // if no dep.link is present, it means the user has no permission to view the entity, thus only id without the link is shown
<li key={dep.id}>{entityTypeLabels[dep.entityTypeId](t)}: [{dep.id}]</li> <li key={dep.id}>{entityTypeLabels[dep.entityTypeId](t)}: [{dep.id}]</li>
)} )}
{err.data.andMore && <li>{t('andMore')}</li>} {err.data.andMore && <li>{t('andMore')}</li>}
</ul> </ul>
</>
:
<p>{t('Cannot delete {{name}} due to hidden dependencies', {name})}</p>
}
</div> </div>
); );
} }
@ -143,10 +151,11 @@ export class DeleteModalDialog extends Component {
visible: PropTypes.bool.isRequired, visible: PropTypes.bool.isRequired,
stateOwner: PropTypes.object.isRequired, stateOwner: PropTypes.object.isRequired,
deleteUrl: PropTypes.string.isRequired, deleteUrl: PropTypes.string.isRequired,
backUrl: PropTypes.string, backUrl: PropTypes.string.isRequired,
successUrl: PropTypes.string, successUrl: PropTypes.string.isRequired,
deletingMsg: PropTypes.string.isRequired, deletingMsg: PropTypes.string.isRequired,
deletedMsg: PropTypes.string.isRequired deletedMsg: PropTypes.string.isRequired,
name: PropTypes.string
} }
async onErrorAsync(err) { async onErrorAsync(err) {
@ -171,7 +180,7 @@ export class DeleteModalDialog extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const owner = this.props.stateOwner; const owner = this.props.stateOwner;
const name = owner.getFormValue('name') || ''; const name = this.props.name || owner.getFormValue('name') || '';
return <RestActionModalDialog return <RestActionModalDialog
title={t('confirmDeletion')} title={t('confirmDeletion')}

View file

@ -9,7 +9,7 @@ import {withComponentMixins} from "./decorator-helpers";
@withComponentMixins([ @withComponentMixins([
withTranslation withTranslation
]) ])
class NamespaceSelect extends Component { export class NamespaceSelect extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
@ -19,7 +19,7 @@ class NamespaceSelect extends Component {
} }
} }
function validateNamespace(t, state) { export function validateNamespace(t, state) {
if (!state.getIn(['namespace', 'value'])) { if (!state.getIn(['namespace', 'value'])) {
state.setIn(['namespace', 'error'], t('namespaceMustBeSelected')); state.setIn(['namespace', 'error'], t('namespaceMustBeSelected'));
} else { } else {
@ -27,7 +27,25 @@ function validateNamespace(t, state) {
} }
} }
export { export function getDefaultNamespace(permissions) {
NamespaceSelect, return permissions.viewUsersNamespace && permissions.createEntityInUsersNamespace ? mailtrainConfig.user.namespace : null;
validateNamespace }
export function namespaceCheckPermissions(createOperation) {
if (mailtrainConfig.user) {
return {
createEntityInUsersNamespace: {
entityTypeId: 'namespace',
entityId: mailtrainConfig.user.namespace,
requiredOperations: [createOperation]
},
viewUsersNamespace: {
entityTypeId: 'namespace',
entityId: mailtrainConfig.user.namespace,
requiredOperations: ['view']
}
}; };
} else {
return {};
}
}

View file

@ -9,13 +9,23 @@ import {getUrl} from "./urls";
import {createComponentMixin, withComponentMixins} from "./decorator-helpers"; import {createComponentMixin, withComponentMixins} from "./decorator-helpers";
import {withTranslation} from "./i18n"; import {withTranslation} from "./i18n";
import shallowEqual from "shallowequal"; import shallowEqual from "shallowequal";
import {checkPermissions} from "./permissions";
async function resolve(route, match, prevResolvedByUrl) { async function resolve(route, match, prevResolverState) {
const resolved = {}; const resolved = {};
const resolvedByUrl = {}; const permissions = {};
const keysToGo = new Set(Object.keys(route.resolve)); const resolverState = {
resolvedByUrl: {},
permissionsBySig: {}
};
prevResolvedByUrl = prevResolvedByUrl || {}; prevResolverState = prevResolverState || {
resolvedByUrl: {},
permissionsBySig: {}
};
async function processResolve() {
const keysToGo = new Set(Object.keys(route.resolve));
while (keysToGo.size > 0) { while (keysToGo.size > 0) {
const urlsToResolve = []; const urlsToResolve = [];
@ -60,10 +70,10 @@ async function resolve(route, match, prevResolvedByUrl) {
const key = keysToResolve[idx]; const key = keysToResolve[idx];
const url = urlsToResolve[idx]; const url = urlsToResolve[idx];
if (url in prevResolvedByUrl) { if (url in prevResolverState.resolvedByUrl) {
const entity = prevResolvedByUrl[url]; const entity = prevResolverState.resolvedByUrl[url];
resolved[key] = entity; resolved[key] = entity;
resolvedByUrl[url] = entity; resolverState.resolvedByUrl[url] = entity;
} else { } else {
urlsToResolveByRest.push(url); urlsToResolveByRest.push(url);
@ -83,7 +93,7 @@ async function resolve(route, match, prevResolvedByUrl) {
for (let idx = 0; idx < keysToResolveByRest.length; idx++) { for (let idx = 0; idx < keysToResolveByRest.length; idx++) {
resolved[keysToResolveByRest[idx]] = resolvedArr[idx].data; resolved[keysToResolveByRest[idx]] = resolvedArr[idx].data;
resolvedByUrl[urlsToResolveByRest[idx]] = resolvedArr[idx].data; resolverState.resolvedByUrl[urlsToResolveByRest[idx]] = resolvedArr[idx].data;
} }
} }
@ -91,12 +101,50 @@ async function resolve(route, match, prevResolvedByUrl) {
keysToGo.delete(key); keysToGo.delete(key);
} }
} }
}
return { resolved, resolvedByUrl }; async function processCheckPermissions() {
const checkPermsRequest = {};
function getSig(checkPermissionsEntry) {
return `${checkPermissionsEntry.entityTypeId}-${checkPermissionsEntry.entityId || ''}-${checkPermissionsEntry.requiredOperations.join(',')}`;
}
for (const key in route.checkPermissions) {
const checkPermissionsEntry = route.checkPermissions[key];
const sig = getSig(checkPermissionsEntry);
if (sig in prevResolverState.permissionsBySig) {
const perm = prevResolverState.permissionsBySig[sig];
permissions[key] = perm;
resolverState.permissionsBySig[sig] = perm;
} else {
checkPermsRequest[key] = checkPermissionsEntry;
}
}
if (Object.keys(checkPermsRequest).length > 0) {
const result = await checkPermissions(checkPermsRequest);
for (const key in checkPermsRequest) {
const checkPermissionsEntry = checkPermsRequest[key];
const perm = result.data[key];
permissions[key] = perm;
resolverState.permissionsBySig[getSig(checkPermissionsEntry)] = perm;
}
}
}
await Promise.all([processResolve(), processCheckPermissions()]);
return { resolved, permissions, resolverState };
} }
export function getRoutes(structure, parentRoute) { export function getRoutes(structure, parentRoute) {
function _getRoutes(urlPrefix, resolve, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) { function _getRoutes(urlPrefix, resolve, checkPermissions, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) {
let routes = []; let routes = [];
for (let routeKey in structure) { for (let routeKey in structure) {
const entry = structure[routeKey]; const entry = structure[routeKey];
@ -115,6 +163,13 @@ export function getRoutes(structure, parentRoute) {
entryResolve = resolve; entryResolve = resolve;
} }
let entryCheckPermissions;
if (entry.checkPermissions) {
entryCheckPermissions = Object.assign({}, checkPermissions, entry.checkPermissions);
} else {
entryCheckPermissions = checkPermissions;
}
let navKeys; let navKeys;
const entryNavs = []; const entryNavs = [];
if (entry.navs) { if (entry.navs) {
@ -145,6 +200,7 @@ export function getRoutes(structure, parentRoute) {
panelInFullScreen: entry.panelInFullScreen, panelInFullScreen: entry.panelInFullScreen,
insideIframe: entry.insideIframe, insideIframe: entry.insideIframe,
resolve: entryResolve, resolve: entryResolve,
checkPermissions: entryCheckPermissions,
parents, parents,
navs: [...navs, ...entryNavs], navs: [...navs, ...entryNavs],
@ -167,12 +223,12 @@ export function getRoutes(structure, parentRoute) {
const childNavs = [...entryNavs]; const childNavs = [...entryNavs];
childNavs[navKeyIdx] = Object.assign({}, childNavs[navKeyIdx], { active: true }); childNavs[navKeyIdx] = Object.assign({}, childNavs[navKeyIdx], { active: true });
routes = routes.concat(_getRoutes(path + '/', entryResolve, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent)); routes = routes.concat(_getRoutes(path + '/', entryResolve, entryCheckPermissions, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
} }
} }
if (entry.children) { if (entry.children) {
routes = routes.concat(_getRoutes(path + '/', entryResolve, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent)); routes = routes.concat(_getRoutes(path + '/', entryResolve, entryCheckPermissions, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
} }
} }
@ -192,10 +248,10 @@ export function getRoutes(structure, parentRoute) {
children: { ...(routeSpec.children || {}), ...(structure.children || {}) } children: { ...(routeSpec.children || {}), ...(structure.children || {}) }
}; };
return _getRoutes(parentRoute.urlPrefix, parentRoute.resolve, parentRoute.parents, { [parentRoute.routeKey]: extStructure }, parentRoute.siblingNavs, parentRoute.primaryMenuComponent, parentRoute.secondaryMenuComponent); return _getRoutes(parentRoute.urlPrefix, parentRoute.resolve, parentRoute.checkPermissions, parentRoute.parents, { [parentRoute.routeKey]: extStructure }, parentRoute.siblingNavs, parentRoute.primaryMenuComponent, parentRoute.secondaryMenuComponent);
} else { } else {
return _getRoutes('', {}, [], { "": structure }, [], null, null); return _getRoutes('', {}, {}, [], { "": structure }, [], null, null);
} }
} }
@ -209,11 +265,13 @@ export class Resolver extends Component {
this.state = { this.state = {
resolved: null, resolved: null,
resolvedByUrl: null permissions: null,
resolverState: null
}; };
if (Object.keys(props.route.resolve).length === 0) { if (Object.keys(props.route.resolve).length === 0 && Object.keys(props.route.checkPermissions).length === 0) {
this.state.resolved = {}; this.state.resolved = {};
this.state.permissions = {};
} }
} }
@ -228,28 +286,31 @@ export class Resolver extends Component {
async resolve(prevMatch) { async resolve(prevMatch) {
const props = this.props; const props = this.props;
if (Object.keys(props.route.resolve).length === 0) { if (Object.keys(props.route.resolve).length === 0 && Object.keys(props.route.checkPermissions).length === 0) {
this.setState({ this.setState({
resolved: {}, resolved: {},
resolvedByUrl: {} permissions: {},
resolverState: null
}); });
} else { } else {
const prevResolvedByUrl = this.state.resolvedByUrl; const prevResolverState = this.state.resolverState;
if (this.state.resolved) { if (this.state.resolverState) {
this.setState({ this.setState({
resolved: null, resolved: null,
resolvedByUrl: null permissions: null,
resolverState: null
}); });
} }
const {resolved, resolvedByUrl} = await resolve(props.route, props.match, prevResolvedByUrl); const {resolved, permissions, resolverState} = await resolve(props.route, props.match, prevResolverState);
if (!this.disregardResolve) { // This is to prevent the warning about setState on discarded component when we immediatelly redirect. if (!this.disregardResolve) { // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
this.setState({ this.setState({
resolved, resolved,
resolvedByUrl permissions,
resolverState
}); });
} }
} }
@ -272,7 +333,7 @@ export class Resolver extends Component {
} }
render() { render() {
return this.props.render(this.state.resolved, this.props); return this.props.render(this.state.resolved, this.state.permissions, this.props);
} }
} }
@ -316,9 +377,9 @@ class SubRoute extends Component {
const route = this.props.route; const route = this.props.route;
const params = this.props.match.params; const params = this.props.match.params;
const render = resolved => { const render = (resolved, permissions) => {
if (resolved) { if (resolved && permissions) {
const subStructure = route.structure(resolved, params); const subStructure = route.structure(resolved, permissions, params);
const routes = getRoutes(subStructure, route); const routes = getRoutes(subStructure, route);
const _renderRoute = route => { const _renderRoute = route => {
@ -354,7 +415,10 @@ export function renderRoute(route, panelRouteCtor, loadingMessageFn, flashMessag
} }
export const SectionContentContext = React.createContext(null); export const SectionContentContext = React.createContext(null);
export const withPageHelpers = createComponentMixin([{context: SectionContentContext, propName: 'sectionContent'}], [withErrorHandling], (TargetClass, InnerClass) => { export const withPageHelpers = createComponentMixin({
contexts: [{context: SectionContentContext, propName: 'sectionContent'}],
deps: [withErrorHandling],
decoratorFn: (TargetClass, InnerClass) => {
InnerClass.prototype.setFlashMessage = function (severity, text) { InnerClass.prototype.setFlashMessage = function (severity, text) {
return this.props.sectionContent.setFlashMessage(severity, text); return this.props.sectionContent.setFlashMessage(severity, text);
}; };
@ -380,4 +444,5 @@ export const withPageHelpers = createComponentMixin([{context: SectionContentCon
}; };
return {}; return {};
}
}); });

View file

@ -268,16 +268,17 @@ class PanelRoute extends Component {
const panelInFullScreen = this.state.panelInFullScreen; const panelInFullScreen = this.state.panelInFullScreen;
const render = resolved => { const render = (resolved, permissions) => {
let primaryMenu = null; let primaryMenu = null;
let secondaryMenu = null; let secondaryMenu = null;
let content = null; let content = null;
if (resolved) { if (resolved && permissions) {
const compProps = { const compProps = {
match: this.props.match, match: this.props.match,
location: this.props.location, location: this.props.location,
resolved, resolved,
permissions,
setPanelInFullScreen: this.setPanelInFullScreen, setPanelInFullScreen: this.setPanelInFullScreen,
panelInFullScreen: this.state.panelInFullScreen panelInFullScreen: this.state.panelInFullScreen
}; };
@ -683,7 +684,9 @@ export class NavDropdown extends Component {
} }
export const requiresAuthenticatedUser = createComponentMixin([], [withPageHelpers], (TargetClass, InnerClass) => { export const requiresAuthenticatedUser = createComponentMixin({
deps: [withPageHelpers],
decoratorFn: (TargetClass, InnerClass) => {
class RequiresAuthenticatedUser extends React.Component { class RequiresAuthenticatedUser extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -698,6 +701,7 @@ export const requiresAuthenticatedUser = createComponentMixin([], [withPageHelpe
return { return {
cls: RequiresAuthenticatedUser cls: RequiresAuthenticatedUser
}; };
}
}); });
export function getLanguageChooser(t) { export function getLanguageChooser(t) {

View file

@ -3,10 +3,6 @@
import {getUrl} from "./urls"; import {getUrl} from "./urls";
import axios from "./axios"; import axios from "./axios";
async function checkPermissions(request) { export async function checkPermissions(request) {
return await axios.post(getUrl('rest/permissions-check'), request); return await axios.post(getUrl('rest/permissions-check'), request);
} }
export {
checkPermissions
}

View file

@ -4,8 +4,7 @@ import './public-path';
import React, {Component} from 'react'; import React, {Component} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {I18nextProvider} from 'react-i18next'; import {TranslationRoot, withTranslation} from './i18n';
import i18n, {withTranslation} from './i18n';
import {parentRPC, UntrustedContentRoot} from './untrusted'; import {parentRPC, UntrustedContentRoot} from './untrusted';
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import styles from "./sandboxed-ckeditor.scss"; import styles from "./sandboxed-ckeditor.scss";
@ -126,9 +125,9 @@ export default function() {
parentRPC.init(); parentRPC.init();
ReactDOM.render( ReactDOM.render(
<I18nextProvider i18n={ i18n }> <TranslationRoot>
<UntrustedContentRoot render={props => <CKEditorSandbox {...props} />} /> <UntrustedContentRoot render={props => <CKEditorSandbox {...props} />} />
</I18nextProvider>, </TranslationRoot>,
document.getElementById('root') document.getElementById('root')
); );
}; };

View file

@ -4,8 +4,7 @@ import './public-path';
import React, {Component} from 'react'; import React, {Component} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {I18nextProvider} from 'react-i18next'; import {TranslationRoot, withTranslation} from './i18n';
import i18n, {withTranslation} from './i18n';
import {parentRPC, UntrustedContentRoot} from './untrusted'; import {parentRPC, UntrustedContentRoot} from './untrusted';
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import styles from "./sandboxed-codeeditor.scss"; import styles from "./sandboxed-codeeditor.scss";
@ -211,9 +210,9 @@ export default function() {
parentRPC.init(); parentRPC.init();
ReactDOM.render( ReactDOM.render(
<I18nextProvider i18n={ i18n }> <TranslationRoot>
<UntrustedContentRoot render={props => <CodeEditorSandbox {...props} />} /> <UntrustedContentRoot render={props => <CodeEditorSandbox {...props} />} />
</I18nextProvider>, </TranslationRoot>,
document.getElementById('root') document.getElementById('root')
); );
}; };

View file

@ -4,8 +4,7 @@ import './public-path';
import React, {Component} from 'react'; import React, {Component} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {I18nextProvider} from 'react-i18next'; import {TranslationRoot, withTranslation} from './i18n';
import i18n, {withTranslation} from './i18n';
import {parentRPC, UntrustedContentRoot} from './untrusted'; import {parentRPC, UntrustedContentRoot} from './untrusted';
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls"; import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
@ -626,9 +625,9 @@ export default function() {
parentRPC.init(); parentRPC.init();
ReactDOM.render( ReactDOM.render(
<I18nextProvider i18n={ i18n }> <TranslationRoot>
<UntrustedContentRoot render={props => <GrapesJSSandbox {...props} />} /> <UntrustedContentRoot render={props => <GrapesJSSandbox {...props} />} />
</I18nextProvider>, </TranslationRoot>,
document.getElementById('root') document.getElementById('root')
); );
}; };

View file

@ -4,8 +4,7 @@ import './public-path';
import React, {Component} from 'react'; import React, {Component} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {I18nextProvider} from 'react-i18next'; import {TranslationRoot, withTranslation} from './i18n';
import i18n, {withTranslation} from './i18n';
import {parentRPC, UntrustedContentRoot} from './untrusted'; import {parentRPC, UntrustedContentRoot} from './untrusted';
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls"; import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
@ -149,9 +148,9 @@ export default function() {
parentRPC.init(); parentRPC.init();
ReactDOM.render( ReactDOM.render(
<I18nextProvider i18n={ i18n }> <TranslationRoot>
<UntrustedContentRoot render={props => <MosaicoSandbox {...props} />} /> <UntrustedContentRoot render={props => <MosaicoSandbox {...props} />} />
</I18nextProvider>, </TranslationRoot>,
document.getElementById('root') document.getElementById('root')
); );
}; };

View file

@ -37,6 +37,7 @@ const TableSelectMode = {
class Table extends Component { class Table extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.mounted = false;
this.selectionMap = this.getSelectionMap(props); this.selectionMap = this.getSelectionMap(props);
} }
@ -184,6 +185,8 @@ class Table extends Component {
} }
componentDidMount() { componentDidMount() {
this.mounted = true;
const columns = this.props.columns.slice(); const columns = this.props.columns.slice();
// XSS protection and actions rendering // XSS protection and actions rendering
@ -364,12 +367,13 @@ class Table extends Component {
} }
componentWillUnmount() { componentWillUnmount() {
this.mounted = false;
clearInterval(this.refreshIntervalId); clearInterval(this.refreshIntervalId);
clearTimeout(this.refreshTimeoutId); clearTimeout(this.refreshTimeoutId);
} }
async notifySelection(eventCallback, newSelectionMap) { async notifySelection(eventCallback, newSelectionMap) {
if (eventCallback) { if (this.mounted && eventCallback) {
const selPairs = Array.from(newSelectionMap).sort((l, r) => l[0] - r[0]); const selPairs = Array.from(newSelectionMap).sort((l, r) => l[0] - r[0]);
let data = selPairs.map(entry => entry[1]); let data = selPairs.map(entry => entry[1]);

View file

@ -33,8 +33,10 @@ class TreeTable extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.mounted = false;
this.state = { this.state = {
treeData: [] treeData: null
}; };
if (props.data) { if (props.data) {
@ -50,9 +52,9 @@ class TreeTable extends Component {
} }
refresh() { refresh() {
if (this.tree) { if (this.tree && !this.props.data && this.props.dataUrl) {
this.tree.reload(this.sanitizeTreeData(this.state.treeData)); // noinspection JSIgnoredPromiseFromCall
this.updateSelection(); this.loadData();
} }
} }
@ -68,10 +70,12 @@ class TreeTable extends Component {
} }
} }
if (this.mounted) {
this.setState({ this.setState({
treeData treeData
}); });
} }
}
static propTypes = { static propTypes = {
dataUrl: PropTypes.string, dataUrl: PropTypes.string,
@ -95,6 +99,7 @@ class TreeTable extends Component {
// XSS protection // XSS protection
sanitizeTreeData(unsafeData) { sanitizeTreeData(unsafeData) {
const data = []; const data = [];
if (unsafeData) {
for (const unsafeEntry of unsafeData) { for (const unsafeEntry of unsafeData) {
const entry = Object.assign({}, unsafeEntry); const entry = Object.assign({}, unsafeEntry);
entry.unsanitizedTitle = entry.title; entry.unsanitizedTitle = entry.title;
@ -105,10 +110,14 @@ class TreeTable extends Component {
} }
data.push(entry); data.push(entry);
} }
}
return data; return data;
} }
componentDidMount() { componentDidMount() {
this.mounted = true;
if (!this.props.data && this.props.dataUrl) { if (!this.props.data && this.props.dataUrl) {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
this.loadData(); this.loadData();
@ -187,6 +196,7 @@ class TreeTable extends Component {
createNode: createNodeFn, createNode: createNodeFn,
checkbox: this.selectMode === TreeSelectMode.MULTI, checkbox: this.selectMode === TreeSelectMode.MULTI,
activate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null), activate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null),
deactivate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null),
select: (this.selectMode === TreeSelectMode.MULTI ? ::this.onSelect : null), select: (this.selectMode === TreeSelectMode.MULTI ? ::this.onSelect : null),
}; };
@ -221,6 +231,10 @@ class TreeTable extends Component {
} }
} }
componentWillUnmount() {
this.mounted = false;
}
updateSelection() { updateSelection() {
const tree = this.tree; const tree = this.tree;
if (this.selectMode === TreeSelectMode.MULTI) { if (this.selectMode === TreeSelectMode.MULTI) {
@ -231,7 +245,22 @@ class TreeTable extends Component {
tree.enableUpdate(true); tree.enableUpdate(true);
} else if (this.selectMode === TreeSelectMode.SINGLE) { } else if (this.selectMode === TreeSelectMode.SINGLE) {
this.tree.activateKey(this.stringifyKey(this.props.selection)); let selection = this.stringifyKey(this.props.selection);
if (this.state.treeData) {
if (!tree.getNodeByKey(selection)) {
selection = null;
}
if (selection === null && !this.tree.getActiveNode()) {
// This covers the case when we mount the tree and selection is not present in the tree.
// At this point, nothing is selected, so the onActive event won't trigger. So we have to
// call it manually, so that the form can update and set null instead of the invalid selection.
this.onActivate();
} else {
tree.activateKey(selection);
}
}
} }
} }
@ -260,7 +289,8 @@ class TreeTable extends Component {
// Single-select // Single-select
onActivate(event, data) { onActivate(event, data) {
const selection = this.destringifyKey(this.tree.getActiveNode().key); const activeNode = this.tree.getActiveNode();
const selection = activeNode ? this.destringifyKey(activeNode.key) : null;
if (selection !== this.props.selection) { if (selection !== this.props.selection) {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
@ -288,7 +318,7 @@ class TreeTable extends Component {
if (updated) { if (updated) {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
this.onSelectionChanged(selection); this.onSelectionChanged(newSel);
} }
} }

View file

@ -22,10 +22,9 @@ import {
} from '../lib/form'; } from '../lib/form';
import {withErrorHandling} from '../lib/error-handling'; import {withErrorHandling} from '../lib/error-handling';
import {DeleteModalDialog} from '../lib/modals'; import {DeleteModalDialog} from '../lib/modals';
import {NamespaceSelect, validateNamespace} from '../lib/namespace'; import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {FieldWizard, UnsubscriptionMode} from '../../../shared/lists'; import {FieldWizard, UnsubscriptionMode} from '../../../shared/lists';
import styles from "../lib/styles.scss"; import styles from "../lib/styles.scss";
import mailtrainConfig from 'mailtrainConfig';
import {getMailerTypes} from "../send-configurations/helpers"; import {getMailerTypes} from "../send-configurations/helpers";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
@ -49,7 +48,8 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
entity: PropTypes.object entity: PropTypes.object,
permissions: PropTypes.object
} }
getFormValuesMutator(data) { getFormValuesMutator(data) {
@ -86,7 +86,7 @@ export default class CUD extends Component {
contact_email: '', contact_email: '',
homepage: '', homepage: '',
unsubscription_mode: UnsubscriptionMode.ONE_STEP, unsubscription_mode: UnsubscriptionMode.ONE_STEP,
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
to_name: '', to_name: '',
fieldWizard: FieldWizard.FIRST_LAST_NAME, fieldWizard: FieldWizard.FIRST_LAST_NAME,
send_configuration: null, send_configuration: null,

View file

@ -3,13 +3,13 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n'; import {withTranslation} from '../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page'; import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling'; import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table'; import {Table} from '../lib/table';
import {Icon} from "../lib/bootstrap-components"; import {Icon} from "../lib/bootstrap-components";
import {checkPermissions} from "../lib/permissions";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals"; import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import {withForm} from "../lib/form"; import {withForm} from "../lib/form";
import PropTypes from 'prop-types';
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -26,28 +26,17 @@ export default class List extends Component {
tableRestActionDialogInit(this); tableRestActionDialogInit(this);
} }
@withAsyncErrorHandler static propTypes = {
async fetchPermissions() { permissions: PropTypes.object
const result = await checkPermissions({
createList: {
entityTypeId: 'namespace',
requiredOperations: ['createList']
}
});
this.setState({
createPermitted: result.data.createList
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createList;
const customFormsPermitted = permissions.createCustomForm || permissions.viewCustomForm;
const columns = [ const columns = [
{ {
data: 1, data: 1,
@ -130,12 +119,14 @@ export default class List extends Component {
return ( return (
<div> <div>
{tableRestActionDialogRender(this)} {tableRestActionDialogRender(this)}
{this.state.createPermitted &&
<Toolbar> <Toolbar>
{ createPermitted &&
<LinkButton to="/lists/create" className="btn-primary" icon="plus" label={t('createList')}/> <LinkButton to="/lists/create" className="btn-primary" icon="plus" label={t('createList')}/>
<LinkButton to="/lists/forms" className="btn-primary" label={t('customForms-1')}/>
</Toolbar>
} }
{ customFormsPermitted &&
<LinkButton to="/lists/forms" className="btn-primary" label={t('customForms-1')}/>
}
</Toolbar>
<Title>{t('lists')}</Title> <Title>{t('lists')}</Title>

View file

@ -18,6 +18,7 @@ import {
InputField, InputField,
StaticField, StaticField,
TableSelect, TableSelect,
TextArea,
withForm, withForm,
withFormErrorHandlers withFormErrorHandlers
} from '../../lib/form'; } from '../../lib/form';
@ -83,6 +84,10 @@ export default class CUD extends Component {
data.default_value = ''; data.default_value = '';
} }
if (data.help === null) {
data.help = '';
}
data.isInGroup = data.group !== null; data.isInGroup = data.group !== null;
data.enumOptions = ''; data.enumOptions = '';
@ -124,6 +129,10 @@ export default class CUD extends Component {
data.default_value = null; data.default_value = null;
} }
if (data.help.trim() === '') {
data.help = null;
}
if (!data.isInGroup) { if (!data.isInGroup) {
data.group = null; data.group = null;
} }
@ -164,7 +173,7 @@ export default class CUD extends Component {
data.orderManageBefore = Number.parseInt(data.orderManageBefore) || data.orderManageBefore; data.orderManageBefore = Number.parseInt(data.orderManageBefore) || data.orderManageBefore;
} }
return filterData(data, ['name', 'key', 'default_value', 'type', 'group', 'settings', return filterData(data, ['name', 'help', 'key', 'default_value', 'type', 'group', 'settings',
'orderListBefore', 'orderSubscribeBefore', 'orderManageBefore']); 'orderListBefore', 'orderSubscribeBefore', 'orderManageBefore']);
} }
@ -178,6 +187,7 @@ export default class CUD extends Component {
type: 'text', type: 'text',
key: '', key: '',
default_value: '', default_value: '',
help: '',
group: null, group: null,
isInGroup: false, isInGroup: false,
renderTemplate: '', renderTemplate: '',
@ -501,6 +511,8 @@ export default class CUD extends Component {
<InputField id="key" label={t('mergeTag-1')}/> <InputField id="key" label={t('mergeTag-1')}/>
<TextArea id="help" label={t('Help text')}/>
{fieldSettings} {fieldSettings}
{type !== 'option' && {type !== 'option' &&

View file

@ -10,6 +10,7 @@ import {
AlignedRow, AlignedRow,
Button, Button,
ButtonRow, ButtonRow,
CheckBox,
Dropdown, Dropdown,
Fieldset, Fieldset,
filterData, filterData,
@ -22,7 +23,7 @@ import {
withFormErrorHandlers withFormErrorHandlers
} from '../../lib/form'; } from '../../lib/form';
import {withErrorHandling} from '../../lib/error-handling'; import {withErrorHandling} from '../../lib/error-handling';
import {NamespaceSelect, validateNamespace} from '../../lib/namespace'; import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../../lib/namespace';
import {DeleteModalDialog} from "../../lib/modals"; import {DeleteModalDialog} from "../../lib/modals";
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {getTrustedUrl, getUrl} from "../../lib/urls"; import {getTrustedUrl, getUrl} from "../../lib/urls";
@ -279,7 +280,8 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
entity: PropTypes.object entity: PropTypes.object,
permissions: PropTypes.object
} }
@ -297,7 +299,10 @@ export default class CUD extends Component {
} }
submitFormValuesMutator(data) { submitFormValuesMutator(data) {
return filterData(data, ['name', 'description', 'layout', 'form_input_style', 'namespace', return filterData(data, ['name', 'description', 'namespace',
'fromExistingEntity', 'existingEntity',
'layout', 'form_input_style',
'web_subscribe', 'web_subscribe',
'web_confirm_subscription_notice', 'web_confirm_subscription_notice',
'mail_confirm_subscription_html', 'mail_confirm_subscription_html',
@ -330,8 +335,10 @@ export default class CUD extends Component {
const data = { const data = {
name: '', name: '',
description: '', description: '',
fromExistingEntity: false,
existingEntity: null,
selectedTemplate: 'layout', selectedTemplate: 'layout',
namespace: mailtrainConfig.user.namespace namespace: getDefaultNamespace(this.props.permissions)
}; };
this.supplyDefaults(data); this.supplyDefaults(data);
@ -350,6 +357,13 @@ export default class CUD extends Component {
validateNamespace(t, state); validateNamespace(t, state);
if (state.getIn(['fromExistingEntity', 'value']) && !state.getIn(['existingEntity', 'value'])) {
state.setIn(['existingEntity', 'error'], t('sourceCustomFormsMustNotBeEmpty'));
} else {
state.setIn(['existingEntity', 'error'], null);
}
let formsServerValidationRunning = false; let formsServerValidationRunning = false;
const formsErrors = []; const formsErrors = [];
@ -386,10 +400,10 @@ export default class CUD extends Component {
let sendMethod, url; let sendMethod, url;
if (this.props.entity) { if (this.props.entity) {
sendMethod = FormSendMethod.PUT; sendMethod = FormSendMethod.PUT;
url = `rest/forms/${this.props.entity.id}` url = `rest/forms/${this.props.entity.id}`;
} else { } else {
sendMethod = FormSendMethod.POST; sendMethod = FormSendMethod.POST;
url = 'rest/forms' url = 'rest/forms';
} }
this.disableForm(); this.disableForm();
@ -456,6 +470,12 @@ export default class CUD extends Component {
}); });
} }
const customFormsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('namespace') }
];
const listsColumns = [ const listsColumns = [
{ data: 0, title: "#" }, { data: 0, title: "#" },
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
@ -488,13 +508,21 @@ export default class CUD extends Component {
<NamespaceSelect/> <NamespaceSelect/>
{!isEdit &&
<CheckBox id="fromExistingEntity" label={t('customForms')} text={t('cloneFromAnExistingCustomForms')}/>
}
{this.getFormValue('fromExistingEntity') ?
<TableSelect id="existingEntity" label={t('Source custom forms')} withHeader dropdown dataUrl='rest/forms-table' columns={customFormsColumns} selectionLabelIndex={1} />
:
<>
<Fieldset label={t('formsPreview')}> <Fieldset label={t('formsPreview')}>
<TableSelect id="previewList" label={t('listToPreviewOn')} withHeader dropdown dataUrl='rest/lists-table' columns={listsColumns} selectionLabelIndex={1} help={t('selectListWhoseFieldsWillBeUsedToPreview')}/> <TableSelect id="previewList" label={t('listToPreviewOn')} withHeader dropdown dataUrl='rest/lists-table' columns={listsColumns} selectionLabelIndex={1} help={t('selectListWhoseFieldsWillBeUsedToPreview')}/>
{ previewListId && { previewListId &&
<div> <div>
<AlignedRow> <AlignedRow>
<div className="help-block"> <div>
<small> <small>
{t('noteTheseLinksAreSolelyForAQuickPreview')} {t('noteTheseLinksAreSolelyForAQuickPreview')}
</small> </small>
@ -549,6 +577,8 @@ export default class CUD extends Component {
<ACEEditor id={selectedTemplate} height="500px" mode={this.templateSettings[selectedTemplate].mode}/> <ACEEditor id={selectedTemplate} height="500px" mode={this.templateSettings[selectedTemplate].mode}/>
</Fieldset> </Fieldset>
} }
</>
}
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('save')}/>

View file

@ -3,12 +3,12 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import {withTranslation} from '../../lib/i18n'; import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page'; import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling'; import {withErrorHandling} from '../../lib/error-handling';
import {Table} from '../../lib/table'; import {Table} from '../../lib/table';
import {Icon} from "../../lib/bootstrap-components"; import {Icon} from "../../lib/bootstrap-components";
import {checkPermissions} from "../../lib/permissions";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals"; import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
import {withComponentMixins} from "../../lib/decorator-helpers"; import {withComponentMixins} from "../../lib/decorator-helpers";
import PropTypes from 'prop-types';
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -24,28 +24,16 @@ export default class List extends Component {
tableRestActionDialogInit(this); tableRestActionDialogInit(this);
} }
@withAsyncErrorHandler static propTypes = {
async fetchPermissions() { permissions: PropTypes.object
const result = await checkPermissions({
createCustomForm: {
entityTypeId: 'namespace',
requiredOperations: ['createCustomForm']
}
});
this.setState({
createPermitted: result.data.createCustomForm
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createCustomForm;
const columns = [ const columns = [
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
{ data: 2, title: t('description') }, { data: 2, title: t('description') },
@ -78,7 +66,7 @@ export default class List extends Component {
return ( return (
<div> <div>
{tableRestActionDialogRender(this)} {tableRestActionDialogRender(this)}
{this.state.createPermitted && {createPermitted &&
<Toolbar> <Toolbar>
<LinkButton to="/lists/forms/create" className="btn-primary" icon="plus" label={t('createCustomForm')}/> <LinkButton to="/lists/forms/create" className="btn-primary" icon="plus" label={t('createCustomForm')}/>
</Toolbar> </Toolbar>

View file

@ -19,13 +19,29 @@ import ImportRunsStatus from './imports/RunStatus';
import Share from '../shares/Share'; import Share from '../shares/Share';
import TriggersList from './TriggersList'; import TriggersList from './TriggersList';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"; import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) { function getMenus(t) {
return { return {
'lists': { 'lists': {
title: t('lists'), title: t('lists'),
link: '/lists', link: '/lists',
panelComponent: ListsList, checkPermissions: {
createList: {
entityTypeId: 'namespace',
requiredOperations: ['createList']
},
createCustomForm: {
entityTypeId: 'namespace',
requiredOperations: ['createCustomForm']
},
viewCustomForm: {
entityTypeId: 'customForm',
requiredOperations: ['view']
},
...namespaceCheckPermissions('createList')
},
panelRender: props => <ListsList permissions={props.permissions}/>,
children: { children: {
':listId([0-9]+)': { ':listId([0-9]+)': {
title: resolved => t('listName', {name: ellipsizeBreadcrumbLabel(resolved.list.name)}), title: resolved => t('listName', {name: ellipsizeBreadcrumbLabel(resolved.list.name)}),
@ -70,7 +86,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/lists/${params.listId}/edit`, link: params => `/lists/${params.listId}/edit`,
visible: resolved => resolved.list.permissions.includes('edit'), visible: resolved => resolved.list.permissions.includes('edit'),
panelRender: props => <ListsCUD action={props.match.params.action} entity={props.resolved.list} /> panelRender: props => <ListsCUD action={props.match.params.action} entity={props.resolved.list} permissions={props.permissions} />
}, },
fields: { fields: {
title: t('fields'), title: t('fields'),
@ -191,12 +207,15 @@ function getMenus(t) {
}, },
create: { create: {
title: t('create'), title: t('create'),
panelRender: props => <ListsCUD action="create" /> panelRender: props => <ListsCUD action="create" permissions={props.permissions} />
}, },
forms: { forms: {
title: t('customForms-1'), title: t('customForms-1'),
link: '/lists/forms', link: '/lists/forms',
panelComponent: FormsList, checkPermissions: {
...namespaceCheckPermissions('createCustomForm')
},
panelRender: props => <FormsList permissions={props.permissions}/>,
children: { children: {
':formsId([0-9]+)': { ':formsId([0-9]+)': {
title: resolved => t('customFormsName', {name: ellipsizeBreadcrumbLabel(resolved.forms.name)}), title: resolved => t('customFormsName', {name: ellipsizeBreadcrumbLabel(resolved.forms.name)}),
@ -209,7 +228,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/lists/forms/${params.formsId}/edit`, link: params => `/lists/forms/${params.formsId}/edit`,
visible: resolved => resolved.forms.permissions.includes('edit'), visible: resolved => resolved.forms.permissions.includes('edit'),
panelRender: props => <FormsCUD action={props.match.params.action} entity={props.resolved.forms} /> panelRender: props => <FormsCUD action={props.match.params.action} entity={props.resolved.forms} permissions={props.permissions} />
}, },
share: { share: {
title: t('share'), title: t('share'),
@ -221,7 +240,7 @@ function getMenus(t) {
}, },
create: { create: {
title: t('create'), title: t('create'),
panelRender: props => <FormsCUD action="create" /> panelRender: props => <FormsCUD action="create" permissions={props.permissions} />
} }
} }
} }

View file

@ -51,6 +51,11 @@ export default class CUD extends Component {
extra: ['id'] extra: ['id']
}, },
}); });
this.timezoneOptions = [
{ key: '', label: t('notSelected') },
...moment.tz.names().map(tz => ({ key: tz.toLowerCase(), label: tz }))
];
} }
static propTypes = { static propTypes = {
@ -185,11 +190,6 @@ export default class CUD extends Component {
const statusOptions = Object.keys(this.subscriptionStatusLabels) const statusOptions = Object.keys(this.subscriptionStatusLabels)
.map(key => ({key, label: this.subscriptionStatusLabels[key]})); .map(key => ({key, label: this.subscriptionStatusLabels[key]}));
const tzOptions = [
{ key: '', label: t('notSelected') },
...moment.tz.names().map(tz => ({ key: tz.toLowerCase(), label: tz }))
];
const customFields = []; const customFields = [];
for (const fld of this.props.fieldsGrouped) { for (const fld of this.props.fieldsGrouped) {
customFields.push(this.fieldTypes[fld.type].form(fld)); customFields.push(this.fieldTypes[fld.type].form(fld));
@ -219,10 +219,9 @@ export default class CUD extends Component {
<InputField id="email" label={t('email')}/> <InputField id="email" label={t('email')}/>
{customFields} {customFields}
<hr /> <hr />
<Dropdown id="tz" label={t('timezone')} options={tzOptions}/> <Dropdown id="tz" label={t('timezone')} options={this.timezoneOptions}/>
<Dropdown id="status" label={t('subscriptionStatus')} options={statusOptions}/> <Dropdown id="status" label={t('subscriptionStatus')} options={statusOptions}/>

View file

@ -49,7 +49,7 @@ export default class Account extends Component {
} }
submitFormValuesMutator(data) { submitFormValuesMutator(data) {
return filterData(data, ['password']); return filterData(data, ['username', 'password', 'resetToken']);
} }
@withAsyncErrorHandler @withAsyncErrorHandler

View file

@ -24,6 +24,7 @@ import mailtrainConfig from 'mailtrainConfig';
import {getGlobalNamespaceId} from "../../../shared/namespaces"; import {getGlobalNamespaceId} from "../../../shared/namespaces";
import {getUrl} from "../lib/urls"; import {getUrl} from "../lib/urls";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import {getDefaultNamespace} from "../lib/namespace";
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -43,7 +44,8 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
entity: PropTypes.object entity: PropTypes.object,
permissions: PropTypes.object
} }
submitFormValuesMutator(data) { submitFormValuesMutator(data) {
@ -82,11 +84,13 @@ export default class CUD extends Component {
this.removeNsIdSubtree(data); this.removeNsIdSubtree(data);
} }
if (this.isComponentMounted()) {
this.setState({ this.setState({
treeData: data treeData: data
}); });
} }
} }
}
componentDidMount() { componentDidMount() {
if (this.props.entity) { if (this.props.entity) {
@ -95,7 +99,7 @@ export default class CUD extends Component {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: '', description: '',
namespace: mailtrainConfig.user.namespace namespace: getDefaultNamespace(this.props.permissions)
}); });
} }
@ -191,7 +195,7 @@ export default class CUD extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const isEdit = !!this.props.entity; const isEdit = !!this.props.entity;
const canDelete = isEdit && !this.isEditGlobal() && this.props.entity.permissions.includes('delete'); const canDelete = isEdit && !this.isEditGlobal() && mailtrainConfig.user.namespace !== this.props.entity.id && this.props.entity.permissions.includes('delete');
return ( return (
<div> <div>

View file

@ -4,12 +4,13 @@ import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n'; import {withTranslation} from '../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page'; import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {TreeTable} from '../lib/tree'; import {TreeTable} from '../lib/tree';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling'; import {withErrorHandling} from '../lib/error-handling';
import {Icon} from "../lib/bootstrap-components"; import {Icon} from "../lib/bootstrap-components";
import {checkPermissions} from "../lib/permissions";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals"; import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {getGlobalNamespaceId} from "../../../shared/namespaces"; import {getGlobalNamespaceId} from "../../../shared/namespaces";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import mailtrainConfig from 'mailtrainConfig';
import PropTypes from 'prop-types';
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -25,28 +26,16 @@ export default class List extends Component {
tableRestActionDialogInit(this); tableRestActionDialogInit(this);
} }
@withAsyncErrorHandler static propTypes = {
async fetchPermissions() { permissions: PropTypes.object
const result = await checkPermissions({
createNamespace: {
entityTypeId: 'namespace',
requiredOperations: ['createNamespace']
}
});
this.setState({
createPermitted: result.data.createNamespace
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createNamespace;
const actions = node => { const actions = node => {
const actions = []; const actions = [];
@ -64,7 +53,8 @@ export default class List extends Component {
}); });
} }
if (Number.parseInt(node.key) !== getGlobalNamespaceId()) { const namespaceId = Number.parseInt(node.key);
if (namespaceId !== getGlobalNamespaceId() && mailtrainConfig.user.namespace !== namespaceId) {
tableAddDeleteButton(actions, this, node.data.permissions, `rest/namespaces/${node.key}`, node.data.unsanitizedTitle, t('deletingNamespace'), t('namespaceDeleted')); tableAddDeleteButton(actions, this, node.data.permissions, `rest/namespaces/${node.key}`, node.data.unsanitizedTitle, t('deletingNamespace'), t('namespaceDeleted'));
} }
@ -74,7 +64,7 @@ export default class List extends Component {
return ( return (
<div> <div>
{tableRestActionDialogRender(this)} {tableRestActionDialogRender(this)}
{this.state.createPermitted && {createPermitted &&
<Toolbar> <Toolbar>
<LinkButton to="/namespaces/create" className="btn-primary" icon="plus" label={t('createNamespace')}/> <LinkButton to="/namespaces/create" className="btn-primary" icon="plus" label={t('createNamespace')}/>
</Toolbar> </Toolbar>

View file

@ -5,13 +5,21 @@ import CUD from './CUD';
import List from './List'; import List from './List';
import Share from '../shares/Share'; import Share from '../shares/Share';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"; import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) { function getMenus(t) {
return { return {
namespaces: { namespaces: {
title: t('namespaces'), title: t('namespaces'),
link: '/namespaces', link: '/namespaces',
panelComponent: List, checkPermissions: {
createNamespace: {
entityTypeId: 'namespace',
requiredOperations: ['createNamespace']
},
...namespaceCheckPermissions('createNamespace')
},
panelRender: props => <List permissions={props.permissions}/>,
children: { children: {
':namespaceId([0-9]+)': { ':namespaceId([0-9]+)': {
title: resolved => t('namespaceName', {name: ellipsizeBreadcrumbLabel(resolved.namespace.name)}), title: resolved => t('namespaceName', {name: ellipsizeBreadcrumbLabel(resolved.namespace.name)}),
@ -24,7 +32,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/namespaces/${params.namespaceId}/edit`, link: params => `/namespaces/${params.namespaceId}/edit`,
visible: resolved => resolved.namespace.permissions.includes('edit'), visible: resolved => resolved.namespace.permissions.includes('edit'),
panelRender: props => <CUD action={props.match.params.action} entity={props.resolved.namespace} /> panelRender: props => <CUD action={props.match.params.action} entity={props.resolved.namespace} permissions={props.permissions} />
}, },
share: { share: {
title: t('share'), title: t('share'),
@ -36,7 +44,7 @@ function getMenus(t) {
}, },
create: { create: {
title: t('create'), title: t('create'),
panelRender: props => <CUD action="create" /> panelRender: props => <CUD action="create" permissions={props.permissions} />
}, },
} }
} }

View file

@ -21,9 +21,8 @@ import {
import axios from '../lib/axios'; import axios from '../lib/axios';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling'; import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import moment from 'moment'; import moment from 'moment';
import {NamespaceSelect, validateNamespace} from '../lib/namespace'; import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals"; import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import {getUrl} from "../lib/urls"; import {getUrl} from "../lib/urls";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
@ -49,7 +48,8 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
entity: PropTypes.object entity: PropTypes.object,
permissions: PropTypes.object
} }
@withAsyncErrorHandler @withAsyncErrorHandler
@ -97,7 +97,7 @@ export default class CUD extends Component {
name: '', name: '',
description: '', description: '',
report_template: null, report_template: null,
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
user_fields: null user_fields: null
}); });
} }

View file

@ -9,10 +9,10 @@ import moment from 'moment';
import axios from '../lib/axios'; import axios from '../lib/axios';
import {ReportState} from '../../../shared/reports'; import {ReportState} from '../../../shared/reports';
import {Icon} from "../lib/bootstrap-components"; import {Icon} from "../lib/bootstrap-components";
import {checkPermissions} from "../lib/permissions";
import {getUrl} from "../lib/urls"; import {getUrl} from "../lib/urls";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals"; import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import PropTypes from 'prop-types';
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -28,36 +28,8 @@ export default class List extends Component {
tableRestActionDialogInit(this); tableRestActionDialogInit(this);
} }
@withAsyncErrorHandler static propTypes = {
async fetchPermissions() { permissions: PropTypes.object
const result = await checkPermissions({
createReport: {
entityTypeId: 'namespace',
requiredOperations: ['createReport']
},
executeReportTemplate: {
entityTypeId: 'reportTemplate',
requiredOperations: ['execute']
},
createReportTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createReportTemplate']
},
viewReportTemplate: {
entityTypeId: 'reportTemplate',
requiredOperations: ['view']
}
});
this.setState({
createPermitted: result.data.createReport && result.data.executeReportTemplate,
templatesPermitted: result.data.createReportTemplate || result.data.viewReportTemplate
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
} }
@withAsyncErrorHandler @withAsyncErrorHandler
@ -75,6 +47,10 @@ export default class List extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createReport && permissions.executeReportTemplate;
const templatesPermitted = permissions.createReportTemplate || permissions.viewReportTemplate;
const columns = [ const columns = [
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
{ data: 2, title: t('template') }, { data: 2, title: t('template') },
@ -176,10 +152,10 @@ export default class List extends Component {
<div> <div>
{tableRestActionDialogRender(this)} {tableRestActionDialogRender(this)}
<Toolbar> <Toolbar>
{this.state.createPermitted && {createPermitted &&
<LinkButton to="/reports/create" className="btn-primary" icon="plus" label={t('createReport')}/> <LinkButton to="/reports/create" className="btn-primary" icon="plus" label={t('createReport')}/>
} }
{this.state.templatesPermitted && {templatesPermitted &&
<LinkButton to="/reports/templates" className="btn-primary" label={t('reportTemplates')}/> <LinkButton to="/reports/templates" className="btn-primary" label={t('reportTemplates')}/>
} }
</Toolbar> </Toolbar>

View file

@ -10,6 +10,7 @@ import Share from '../shares/Share';
import {ReportState} from '../../../shared/reports'; import {ReportState} from '../../../shared/reports';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"; import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) { function getMenus(t) {
@ -17,7 +18,26 @@ function getMenus(t) {
'reports': { 'reports': {
title: t('reports'), title: t('reports'),
link: '/reports', link: '/reports',
panelComponent: ReportsList, checkPermissions: {
createReport: {
entityTypeId: 'namespace',
requiredOperations: ['createReport']
},
executeReportTemplate: {
entityTypeId: 'reportTemplate',
requiredOperations: ['execute']
},
createReportTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createReportTemplate']
},
viewReportTemplate: {
entityTypeId: 'reportTemplate',
requiredOperations: ['view']
},
...namespaceCheckPermissions('createReport')
},
panelRender: props => <ReportsList permissions={props.permissions}/>,
children: { children: {
':reportId([0-9]+)': { ':reportId([0-9]+)': {
title: resolved => t('reportName', {name: ellipsizeBreadcrumbLabel(resolved.report.name)}), title: resolved => t('reportName', {name: ellipsizeBreadcrumbLabel(resolved.report.name)}),
@ -30,7 +50,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/reports/${params.reportId}/edit`, link: params => `/reports/${params.reportId}/edit`,
visible: resolved => resolved.report.permissions.includes('edit'), visible: resolved => resolved.report.permissions.includes('edit'),
panelRender: props => <ReportsCUD action={props.match.params.action} entity={props.resolved.report} /> panelRender: props => <ReportsCUD action={props.match.params.action} entity={props.resolved.report} permissions={props.permissions} />
}, },
view: { view: {
title: t('view'), title: t('view'),
@ -59,12 +79,15 @@ function getMenus(t) {
}, },
create: { create: {
title: t('create'), title: t('create'),
panelRender: props => <ReportsCUD action="create" /> panelRender: props => <ReportsCUD action="create" permissions={props.permissions} />
}, },
templates: { templates: {
title: t('templates'), title: t('templates'),
link: '/reports/templates', link: '/reports/templates',
panelComponent: ReportTemplatesList, checkPermissions: {
...namespaceCheckPermissions('createReportTemplate')
},
panelRender: props => <ReportTemplatesList permissions={props.permissions}/>,
children: { children: {
':templateId([0-9]+)': { ':templateId([0-9]+)': {
title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}), title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}),
@ -77,7 +100,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/reports/templates/${params.templateId}/edit`, link: params => `/reports/templates/${params.templateId}/edit`,
visible: resolved => mailtrainConfig.globalPermissions.createJavascriptWithROAccess && resolved.template.permissions.includes('edit'), visible: resolved => mailtrainConfig.globalPermissions.createJavascriptWithROAccess && resolved.template.permissions.includes('edit'),
panelRender: props => <ReportTemplatesCUD action={props.match.params.action} entity={props.resolved.template} /> panelRender: props => <ReportTemplatesCUD action={props.match.params.action} entity={props.resolved.template} permissions={props.permissions} />
}, },
share: { share: {
title: t('share'), title: t('share'),
@ -90,7 +113,7 @@ function getMenus(t) {
create: { create: {
title: t('create'), title: t('create'),
extraParams: [':wizard?'], extraParams: [':wizard?'],
panelRender: props => <ReportTemplatesCUD action="create" wizard={props.match.params.wizard} /> panelRender: props => <ReportTemplatesCUD action="create" wizard={props.match.params.wizard} permissions={props.permissions} />
} }
} }
} }

View file

@ -19,9 +19,8 @@ import {
withFormErrorHandlers withFormErrorHandlers
} from '../../lib/form'; } from '../../lib/form';
import {withErrorHandling} from '../../lib/error-handling'; import {withErrorHandling} from '../../lib/error-handling';
import {NamespaceSelect, validateNamespace} from '../../lib/namespace'; import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../../lib/namespace';
import {DeleteModalDialog} from "../../lib/modals"; import {DeleteModalDialog} from "../../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import 'brace/mode/javascript'; import 'brace/mode/javascript';
import 'brace/mode/json'; import 'brace/mode/json';
import 'brace/mode/handlebars'; import 'brace/mode/handlebars';
@ -46,7 +45,8 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
wizard: PropTypes.string, wizard: PropTypes.string,
entity: PropTypes.object entity: PropTypes.object,
permissions: PropTypes.object
} }
submitFormValuesMutator(data) { submitFormValuesMutator(data) {
@ -64,7 +64,7 @@ export default class CUD extends Component {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: 'Generates a campaign report listing all subscribers along with open counts.', description: 'Generates a campaign report listing all subscribers along with open counts.',
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
mime_type: 'text/html', mime_type: 'text/html',
user_fields: user_fields:
'[\n' + '[\n' +
@ -114,7 +114,7 @@ export default class CUD extends Component {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: 'Generates a campaign report as CSV that lists all subscribers along with open counts.', description: 'Generates a campaign report as CSV that lists all subscribers along with open counts.',
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
mime_type: 'text/csv', mime_type: 'text/csv',
user_fields: user_fields:
'[\n' + '[\n' +
@ -144,7 +144,7 @@ export default class CUD extends Component {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: 'Generates a campaign report with results are aggregated by "Country" custom field. (Note that this custom field has to be presents in the subscription custom fields.)', description: 'Generates a campaign report with results are aggregated by "Country" custom field. (Note that this custom field has to be presents in the subscription custom fields.)',
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
mime_type: 'text/html', mime_type: 'text/html',
user_fields: user_fields:
'[\n' + '[\n' +
@ -215,7 +215,7 @@ export default class CUD extends Component {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: '', description: '',
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
mime_type: 'text/html', mime_type: 'text/html',
user_fields: '', user_fields: '',
js: '', js: '',

View file

@ -4,13 +4,13 @@ import React, {Component} from 'react';
import {withTranslation} from '../../lib/i18n'; import {withTranslation} from '../../lib/i18n';
import {ButtonDropdown, Icon} from '../../lib/bootstrap-components'; import {ButtonDropdown, Icon} from '../../lib/bootstrap-components';
import {DropdownLink, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page'; import {DropdownLink, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling'; import {withErrorHandling} from '../../lib/error-handling';
import {Table} from '../../lib/table'; import {Table} from '../../lib/table';
import moment from 'moment'; import moment from 'moment';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {checkPermissions} from "../../lib/permissions";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals"; import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
import {withComponentMixins} from "../../lib/decorator-helpers"; import {withComponentMixins} from "../../lib/decorator-helpers";
import PropTypes from 'prop-types';
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -26,28 +26,16 @@ export default class List extends Component {
tableRestActionDialogInit(this); tableRestActionDialogInit(this);
} }
@withAsyncErrorHandler static propTypes = {
async fetchPermissions() { permissions: PropTypes.object
const result = await checkPermissions({
createReportTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createReportTemplate']
}
});
this.setState({
createPermitted: result.data.createReportTemplate && mailtrainConfig.globalPermissions.createJavascriptWithROAccess
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createReportTemplate && mailtrainConfig.globalPermissions.createJavascriptWithROAccess;
const columns = [ const columns = [
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
{ data: 2, title: t('description') }, { data: 2, title: t('description') },
@ -82,7 +70,7 @@ export default class List extends Component {
return ( return (
<div> <div>
{tableRestActionDialogRender(this)} {tableRestActionDialogRender(this)}
{this.state.createPermitted && {createPermitted &&
<Toolbar> <Toolbar>
<ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" label={t('createReportTemplate')}> <ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" label={t('createReportTemplate')}>
<DropdownLink to="/reports/templates/create">{t('blank')}</DropdownLink> <DropdownLink to="/reports/templates/create">{t('blank')}</DropdownLink>

View file

@ -4,8 +4,7 @@ import './lib/public-path';
import React, {Component} from 'react'; import React, {Component} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {I18nextProvider} from 'react-i18next'; import {TranslationRoot, withTranslation} from './lib/i18n';
import i18n, {withTranslation} from './lib/i18n';
import account from './account/root'; import account from './account/root';
import login from './login/root'; import login from './login/root';
import blacklist from './blacklist/root'; import blacklist from './blacklist/root';
@ -139,7 +138,7 @@ class Root extends Component {
} }
export default function() { export default function() {
ReactDOM.render(<I18nextProvider i18n={ i18n }><Root/></I18nextProvider>,document.getElementById('root')); ReactDOM.render(<TranslationRoot><Root/></TranslationRoot>,document.getElementById('root'));
}; };

View file

@ -20,7 +20,7 @@ import {
withFormErrorHandlers withFormErrorHandlers
} from '../lib/form'; } from '../lib/form';
import {withErrorHandling} from '../lib/error-handling'; import {withErrorHandling} from '../lib/error-handling';
import {NamespaceSelect, validateNamespace} from '../lib/namespace'; import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals"; import {DeleteModalDialog} from "../lib/modals";
import {getMailerTypes} from "./helpers"; import {getMailerTypes} from "./helpers";
@ -60,7 +60,8 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
wizard: PropTypes.string, wizard: PropTypes.string,
entity: PropTypes.object entity: PropTypes.object,
permissions: PropTypes.object
} }
onMailerTypeChanged(mutStateDate, key, oldType, type) { onMailerTypeChanged(mutStateDate, key, oldType, type) {
@ -85,7 +86,7 @@ export default class CUD extends Component {
} }
return filterData(data, ['name', 'description', 'from_email', 'from_email_overridable', 'from_name', return filterData(data, ['name', 'description', 'from_email', 'from_email_overridable', 'from_name',
'from_name_overridable', 'reply_to', 'reply_to_overridable', 'subject', 'subject_overridable', 'x_mailer', 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'x_mailer',
'verp_hostname', 'verp_disable_sender_header', 'mailer_type', 'mailer_settings', 'namespace']); 'verp_hostname', 'verp_disable_sender_header', 'mailer_type', 'mailer_settings', 'namespace']);
} }
@ -96,15 +97,13 @@ export default class CUD extends Component {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: '', description: '',
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
from_email: '', from_email: '',
from_email_overridable: false, from_email_overridable: false,
from_name: '', from_name: '',
from_name_overridable: false, from_name_overridable: false,
reply_to: '', reply_to: '',
reply_to_overridable: false, reply_to_overridable: false,
subject: '',
subject_overridable: false,
verpEnabled: false, verpEnabled: false,
verp_hostname: '', verp_hostname: '',
verp_disable_sender_header: false, verp_disable_sender_header: false,
@ -233,8 +232,6 @@ export default class CUD extends Component {
<CheckBox id="from_name_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/> <CheckBox id="from_name_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
<InputField id="reply_to" label={t('defaultReplytoEmail')}/> <InputField id="reply_to" label={t('defaultReplytoEmail')}/>
<CheckBox id="reply_to_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/> <CheckBox id="reply_to_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
<InputField id="subject" label={t('subject')}/>
<CheckBox id="subject_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
<InputField id="x_mailer" label={t('xMailer')}/> <InputField id="x_mailer" label={t('xMailer')}/>
</Fieldset> </Fieldset>

View file

@ -4,13 +4,13 @@ import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n'; import {withTranslation} from '../lib/i18n';
import {Icon} from '../lib/bootstrap-components'; import {Icon} from '../lib/bootstrap-components';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page'; import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling'; import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table'; import {Table} from '../lib/table';
import moment from 'moment'; import moment from 'moment';
import {getMailerTypes} from './helpers'; import {getMailerTypes} from './helpers';
import {checkPermissions} from "../lib/permissions";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals"; import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import PropTypes from 'prop-types';
@withComponentMixins([ @withComponentMixins([
@ -29,28 +29,16 @@ export default class List extends Component {
tableRestActionDialogInit(this); tableRestActionDialogInit(this);
} }
@withAsyncErrorHandler static propTypes = {
async fetchPermissions() { permissions: PropTypes.object
const result = await checkPermissions({
createSendConfiguration: {
entityTypeId: 'namespace',
requiredOperations: ['createSendConfiguration']
}
});
this.setState({
createPermitted: result.data.createSendConfiguration
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createSendConfiguration;
const columns = [ const columns = [
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> }, { data: 2, title: t('id'), render: data => <code>{data}</code> },
@ -87,7 +75,7 @@ export default class List extends Component {
return ( return (
<div> <div>
{tableRestActionDialogRender(this)} {tableRestActionDialogRender(this)}
{this.state.createPermitted && {createPermitted &&
<Toolbar> <Toolbar>
<LinkButton to="/send-configurations/create" className="btn-primary" icon="plus" label={t('createSendConfiguration')}/> <LinkButton to="/send-configurations/create" className="btn-primary" icon="plus" label={t('createSendConfiguration')}/>
</Toolbar> </Toolbar>

View file

@ -21,7 +21,7 @@ export function getMailerTypes(t) {
const initVals = mailerTypes[mailerType].initData(); const initVals = mailerTypes[mailerType].initData();
for (const key in initVals) { for (const key in initVals) {
if (!mutStateData.hasIn([key])) { if (!mutStateData.hasIn([key, 'value']) || mutStateData.getIn([key, 'value']) === undefined) {
mutStateData.setIn([key, 'value'], initVals[key]); mutStateData.setIn([key, 'value'], initVals[key]);
} }
} }

View file

@ -6,6 +6,7 @@ import CUD from './CUD';
import List from './List'; import List from './List';
import Share from '../shares/Share'; import Share from '../shares/Share';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"; import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) { function getMenus(t) {
@ -13,7 +14,14 @@ function getMenus(t) {
'send-configurations': { 'send-configurations': {
title: t('sendConfigurations-1'), title: t('sendConfigurations-1'),
link: '/send-configurations', link: '/send-configurations',
panelComponent: List, checkPermissions: {
createSendConfiguration: {
entityTypeId: 'namespace',
requiredOperations: ['createSendConfiguration']
},
...namespaceCheckPermissions('createSendConfiguration')
},
panelRender: props => <List permissions={props.permissions}/>,
children: { children: {
':sendConfigurationId([0-9]+)': { ':sendConfigurationId([0-9]+)': {
title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.sendConfiguration.name)}), title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.sendConfiguration.name)}),
@ -26,7 +34,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/send-configurations/${params.sendConfigurationId}/edit`, link: params => `/send-configurations/${params.sendConfigurationId}/edit`,
visible: resolved => resolved.sendConfiguration.permissions.includes('edit'), visible: resolved => resolved.sendConfiguration.permissions.includes('edit'),
panelRender: props => <CUD action={props.match.params.action} entity={props.resolved.sendConfiguration} /> panelRender: props => <CUD action={props.match.params.action} entity={props.resolved.sendConfiguration} permissions={props.permissions} />
}, },
share: { share: {
title: t('share'), title: t('share'),
@ -38,7 +46,7 @@ function getMenus(t) {
}, },
create: { create: {
title: t('create'), title: t('create'),
panelRender: props => <CUD action="create" /> panelRender: props => <CUD action="create" permissions={props.permissions} />
} }
} }
} }

View file

@ -139,7 +139,6 @@ export default class Share extends Component {
let usersLabelIndex = 1; let usersLabelIndex = 1;
const usersColumns = [ const usersColumns = [
{ data: 0, title: "#" },
{ data: 1, title: "Username" }, { data: 1, title: "Username" },
]; ];

View file

@ -20,14 +20,14 @@ import {
withFormErrorHandlers withFormErrorHandlers
} from '../lib/form'; } from '../lib/form';
import {withErrorHandling} from '../lib/error-handling'; import {withErrorHandling} from '../lib/error-handling';
import {NamespaceSelect, validateNamespace} from '../lib/namespace'; import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {ContentModalDialog, DeleteModalDialog} from "../lib/modals"; import {ContentModalDialog, DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {getEditForm, getTemplateTypes, getTypeForm} from './helpers'; import {getEditForm, getTagLanguages, getTemplateTypes, getTypeForm} from './helpers';
import axios from '../lib/axios'; import axios from '../lib/axios';
import styles from "../lib/styles.scss"; import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls"; import {getUrl} from "../lib/urls";
import {TestSendModalDialog} from "./TestSendModalDialog"; import {TestSendModalDialog, TestSendModalDialogMode} from "../campaigns/TestSendModalDialog";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import moment from 'moment'; import moment from 'moment';
@ -44,6 +44,7 @@ export default class CUD extends Component {
super(props); super(props);
this.templateTypes = getTemplateTypes(props.t); this.templateTypes = getTemplateTypes(props.t);
this.tagLanguages = getTagLanguages(props.t);
this.state = { this.state = {
showMergeTagReference: false, showMergeTagReference: false,
@ -57,7 +58,8 @@ export default class CUD extends Component {
this.initForm({ this.initForm({
getPreSubmitUpdater: ::this.getPreSubmitFormValuesUpdater, getPreSubmitUpdater: ::this.getPreSubmitFormValuesUpdater,
onChangeBeforeValidation: { onChangeBeforeValidation: {
type: ::this.onTypeChanged type: ::this.onTypeChanged,
tag_language: ::this.onTagLanguageChanged
} }
}); });
@ -73,6 +75,7 @@ export default class CUD extends Component {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
wizard: PropTypes.string, wizard: PropTypes.string,
entity: PropTypes.object, entity: PropTypes.object,
permissions: PropTypes.object,
setPanelInFullScreen: PropTypes.func setPanelInFullScreen: PropTypes.func
} }
@ -82,13 +85,22 @@ export default class CUD extends Component {
} }
} }
onTagLanguageChanged(mutStateData, key, oldTagLanguage, tagLanguage) {
if (tagLanguage) {
const isEdit = !!this.props.entity;
const type = mutStateData.getIn(['type', 'value']);
this.templateTypes[type].afterTagLanguageChange(mutStateData, isEdit);
}
}
getFormValuesMutator(data) { getFormValuesMutator(data) {
this.templateTypes[data.type].afterLoad(data); this.templateTypes[data.type].afterLoad(data);
} }
submitFormValuesMutator(data) { submitFormValuesMutator(data) {
this.templateTypes[data.type].beforeSave(data); this.templateTypes[data.type].beforeSave(data);
return filterData(data, ['name', 'description', 'type', 'data', 'html', 'text', 'namespace']);
return filterData(data, ['name', 'description', 'type', 'tag_language', 'data', 'html', 'text', 'namespace', 'fromExistingEntity', 'existingEntity']);
} }
async getPreSubmitFormValuesUpdater() { async getPreSubmitFormValuesUpdater() {
@ -113,11 +125,12 @@ export default class CUD extends Component {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: '', description: '',
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
type: mailtrainConfig.editors[0], type: mailtrainConfig.editors[0],
tag_language: mailtrainConfig.tagLanguages[0],
fromSourceTemplate: false, fromExistingEntity: false,
sourceTemplate: null, existingEntity: null,
text: '', text: '',
html: '', html: '',
@ -143,10 +156,14 @@ export default class CUD extends Component {
state.setIn(['type', 'error'], t('typeMustBeSelected')); state.setIn(['type', 'error'], t('typeMustBeSelected'));
} }
if (state.getIn(['fromSourceTemplate', 'value']) && !state.getIn(['sourceTemplate', 'value'])) { if (!state.getIn(['tag_language', 'value'])) {
state.setIn(['sourceTemplate', 'error'], t('sourceTemplateMustNotBeEmpty')); state.setIn(['tag_language', 'error'], t('Tag language must be selected'));
}
if (state.getIn(['fromExistingEntity', 'value']) && !state.getIn(['existingEntity', 'value'])) {
state.setIn(['existingEntity', 'error'], t('sourceTemplateMustNotBeEmpty'));
} else { } else {
state.setIn(['sourceTemplate', 'error'], null); state.setIn(['existingEntity', 'error'], null);
} }
validateNamespace(t, state); validateNamespace(t, state);
@ -247,7 +264,8 @@ export default class CUD extends Component {
return { return {
html: exportedData.html, html: exportedData.html,
text: this.getFormValue('text') text: this.getFormValue('text'),
tagLanguage: this.getFormValue('tag_language')
}; };
} }
@ -274,6 +292,11 @@ export default class CUD extends Component {
typeOptions.push({key, label: this.templateTypes[key].typeName}); typeOptions.push({key, label: this.templateTypes[key].typeName});
} }
const tagLanguageOptions = [];
for (const key of mailtrainConfig.tagLanguages) {
tagLanguageOptions.push({key, label: this.tagLanguages[key].name});
}
const typeKey = this.getFormValue('type'); const typeKey = this.getFormValue('type');
let editForm = null; let editForm = null;
@ -290,14 +313,16 @@ export default class CUD extends Component {
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
{ data: 2, title: t('description') }, { data: 2, title: t('description') },
{ data: 3, title: t('type'), render: data => this.templateTypes[data].typeName }, { data: 3, title: t('type'), render: data => this.templateTypes[data].typeName },
{ data: 4, title: t('created'), render: data => moment(data).fromNow() }, { data: 4, title: t('Tag language'), render: data => this.tagLanguages[data].name },
{ data: 5, title: t('namespace') }, { data: 5, title: t('created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('namespace') },
]; ];
return ( return (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}> <div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
{isEdit && {isEdit &&
<TestSendModalDialog <TestSendModalDialog
mode={TestSendModalDialogMode.TEMPLATE}
visible={this.state.showTestSendModal} visible={this.state.showTestSendModal}
onHide={() => this.setState({showTestSendModal: false})} onHide={() => this.setState({showTestSendModal: false})}
getDataAsync={this.sendModalGetDataHandler}/> getDataAsync={this.sendModalGetDataHandler}/>
@ -327,11 +352,11 @@ export default class CUD extends Component {
<TextArea id="description" label={t('description')}/> <TextArea id="description" label={t('description')}/>
{!isEdit && {!isEdit &&
<CheckBox id="fromSourceTemplate" label={t('template')} text={t('cloneFromAnExistingTemplate')}/> <CheckBox id="fromExistingEntity" label={t('template')} text={t('cloneFromAnExistingTemplate')}/>
} }
{this.getFormValue('fromSourceTemplate') ? {this.getFormValue('fromExistingEntity') ?
<TableSelect key="templateSelect" id="sourceTemplate" withHeader dropdown dataUrl='rest/templates-table' columns={templatesColumns} selectionLabelIndex={1} /> <TableSelect id="existingEntity" label={t('Source template')} withHeader dropdown dataUrl='rest/templates-table' columns={templatesColumns} selectionLabelIndex={1} />
: :
<> <>
{isEdit ? {isEdit ?
@ -343,6 +368,8 @@ export default class CUD extends Component {
} }
{typeForm} {typeForm}
<Dropdown id="tag_language" label={t('Tag language')} options={tagLanguageOptions} disabled={isEdit && (!typeKey || this.templateTypes[typeKey].isTagLanguageSelectorDisabledForEdit)}/>
</> </>
} }

View file

@ -4,13 +4,14 @@ import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n'; import {withTranslation} from '../lib/i18n';
import {Icon} from '../lib/bootstrap-components'; import {Icon} from '../lib/bootstrap-components';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page'; import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling'; import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table'; import {Table} from '../lib/table';
import moment from 'moment'; import moment from 'moment';
import {getTemplateTypes} from './helpers'; import {getTagLanguages, getTemplateTypes} from './helpers';
import {checkPermissions} from "../lib/permissions";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals"; import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import PropTypes from 'prop-types';
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -23,52 +24,34 @@ export default class List extends Component {
super(props); super(props);
this.templateTypes = getTemplateTypes(props.t); this.templateTypes = getTemplateTypes(props.t);
this.tagLanguages = getTagLanguages(props.t);
this.state = {}; this.state = {};
tableRestActionDialogInit(this); tableRestActionDialogInit(this);
} }
@withAsyncErrorHandler static propTypes = {
async fetchPermissions() { permissions: PropTypes.object
const result = await checkPermissions({
createTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createTemplate']
},
createMosaicoTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createMosaicoTemplate']
},
viewMosaicoTemplate: {
entityTypeId: 'mosaicoTemplate',
requiredOperations: ['view']
}
});
this.setState({
createPermitted: result.data.createTemplate,
mosaicoTemplatesPermitted: result.data.createMosaicoTemplate || result.data.viewMosaicoTemplate
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createTemplate;
const mosaicoTemplatesPermitted = permissions.createMosaicoTemplate || permissions.viewMosaicoTemplate;
const columns = [ const columns = [
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
{ data: 2, title: t('description') }, { data: 2, title: t('description') },
{ data: 3, title: t('type'), render: data => this.templateTypes[data].typeName }, { data: 3, title: t('type'), render: data => this.templateTypes[data].typeName },
{ data: 4, title: t('created'), render: data => moment(data).fromNow() }, { data: 4, title: t('Tag language'), render: data => this.tagLanguages[data].name },
{ data: 5, title: t('namespace') }, { data: 5, title: t('created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('namespace') },
{ {
actions: data => { actions: data => {
const actions = []; const actions = [];
const perms = data[6]; const perms = data[7];
if (perms.includes('edit')) { if (perms.includes('edit')) {
actions.push({ actions.push({
@ -102,10 +85,10 @@ export default class List extends Component {
<div> <div>
{tableRestActionDialogRender(this)} {tableRestActionDialogRender(this)}
<Toolbar> <Toolbar>
{this.state.createPermitted && {createPermitted &&
<LinkButton to="/templates/create" className="btn-primary" icon="plus" label={t('createTemplate')}/> <LinkButton to="/templates/create" className="btn-primary" icon="plus" label={t('createTemplate')}/>
} }
{this.state.mosaicoTemplatesPermitted && {mosaicoTemplatesPermitted &&
<LinkButton to="/templates/mosaico" className="btn-primary" label={t('mosaicoTemplates')}/> <LinkButton to="/templates/mosaico" className="btn-primary" label={t('mosaicoTemplates')}/>
} }
</Toolbar> </Toolbar>

View file

@ -1,149 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
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";
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export class TestSendModalDialog extends Component {
constructor(props) {
super(props);
this.mailerTypes = getMailerTypes(props.t);
this.initForm({
leaveConfirmation: false
});
}
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('sendingTestEmail'));
const data = await this.props.getDataAsync();
data.listCid = this.getFormValue('list');
data.subscriptionCid = this.getFormValue('testUser');
data.sendConfigurationId = this.getFormValue('sendConfiguration');
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('sendConfigurationHasToBeSelected'))
} else {
state.setIn(['sendConfiguration', 'error'], null);
}
if (!state.getIn(['list', 'value'])) {
state.setIn(['list', 'error'], t('listHasToBeSelected'))
} else {
state.setIn(['list', 'error'], null);
}
if (!state.getIn(['testUser', 'value'])) {
state.setIn(['testUser', 'error'], t('subscriptionHasToBeSelected'))
} else {
state.setIn(['testUser', 'error'], null);
}
}
render() {
const t = this.props.t;
const listId = this.getFormValue('list');
const testUsersColumns = [
{ data: 1, title: t('subscriptionId'), 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('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
{ label: t('cancel'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}>
<Form stateOwner={this} format="wide">
<TableSelect id="sendConfiguration" format="wide" label={t('sendConfiguration')} 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

@ -19,12 +19,24 @@ import {getSandboxUrl} from "../lib/urls";
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {ActionLink, Button} from "../lib/bootstrap-components"; import {ActionLink, Button} from "../lib/bootstrap-components";
import {Trans} from "react-i18next"; import {Trans} from "react-i18next";
import {TagLanguages, renderTag} from "../../../shared/templates";
import styles from "../lib/styles.scss"; import styles from "../lib/styles.scss";
export const ResourceType = { export const ResourceType = {
TEMPLATE: 'template', TEMPLATE: 'template',
CAMPAIGN: 'campaign' CAMPAIGN: 'campaign'
};
export function getTagLanguages(t) {
return {
[TagLanguages.SIMPLE]: {
name: t('Simple')
},
[TagLanguages.HBS]: {
name: t('Handlebars')
}
};
} }
export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEMPLATE) { export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEMPLATE) {
@ -67,23 +79,29 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
render: data => mosaicoTemplateTypes[data].typeName render: data => mosaicoTemplateTypes[data].typeName
}, },
{ {
data: 5, data: 6,
title: t('namespace') title: t('namespace')
}, },
]; ];
templateTypes.mosaico = { templateTypes.mosaico = {
typeName: t('mosaico'), typeName: t('mosaico'),
getTypeForm: (owner, isEdit) => getTypeForm: (owner, isEdit) => {
<TableSelect const tagLanguageKey = owner.getFormValue(prefix + 'tag_language');
if (tagLanguageKey) {
return <TableSelect
id={prefix + 'mosaicoTemplate'} id={prefix + 'mosaicoTemplate'}
label={t('mosaicoTemplate')} label={t('mosaicoTemplate')}
withHeader withHeader
dropdown dropdown
dataUrl='rest/mosaico-templates-table' dataUrl={`rest/mosaico-templates-by-tag-language-table/${tagLanguageKey}`}
columns={mosaicoTemplatesColumns} columns={mosaicoTemplatesColumns}
selectionLabelIndex={1} selectionLabelIndex={1}
disabled={isEdit}/>, disabled={isEdit}/>
} else {
return null;
}
},
getHTMLEditor: owner => getHTMLEditor: owner =>
<AlignedRow <AlignedRow
label={t('templateContentHtml')}> label={t('templateContentHtml')}>
@ -144,6 +162,12 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => { afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'mosaico'); initFieldsIfMissing(mutState, 'mosaico');
}, },
afterTagLanguageChange: (mutState, isEdit) => {
if (!isEdit) {
mutState.setIn([prefix + 'mosaicoTemplate', 'value'], null);
}
},
isTagLanguageSelectorDisabledForEdit: true,
validate: state => { validate: state => {
const mosaicoTemplate = state.getIn([prefix + 'mosaicoTemplate', 'value']); const mosaicoTemplate = state.getIn([prefix + 'mosaicoTemplate', 'value']);
if (!mosaicoTemplate) { if (!mosaicoTemplate) {
@ -230,6 +254,9 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => { afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'mosaicoWithFsTemplate'); initFieldsIfMissing(mutState, 'mosaicoWithFsTemplate');
}, },
afterTagLanguageChange: (mutState, isEdit) => {
},
isTagLanguageSelectorDisabledForEdit: false,
validate: state => { validate: state => {
} }
}; };
@ -317,6 +344,9 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => { afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'grapesjs'); initFieldsIfMissing(mutState, 'grapesjs');
}, },
afterTagLanguageChange: (mutState, isEdit) => {
},
isTagLanguageSelectorDisabledForEdit: false,
validate: state => { validate: state => {
} }
}; };
@ -376,6 +406,9 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => { afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'ckeditor4'); initFieldsIfMissing(mutState, 'ckeditor4');
}, },
afterTagLanguageChange: (mutState, isEdit) => {
},
isTagLanguageSelectorDisabledForEdit: false,
validate: state => { validate: state => {
} }
}; };
@ -460,6 +493,9 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => { afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'codeeditor'); initFieldsIfMissing(mutState, 'codeeditor');
}, },
afterTagLanguageChange: (mutState, isEdit) => {
},
isTagLanguageSelectorDisabledForEdit: false,
validate: state => { validate: state => {
} }
}; };
@ -471,6 +507,39 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
export function getEditForm(owner, typeKey, prefix = '') { export function getEditForm(owner, typeKey, prefix = '') {
const t = owner.props.t; const t = owner.props.t;
const tagLanguage = owner.getFormValue(prefix + 'tag_language');
const tg = tag => renderTag(tagLanguage, tag);
let instructions = null;
if (tagLanguage === TagLanguages.SIMPLE) {
instructions = (
<>
<Trans i18nKey="mergeTagsAreTagsThatAreReplacedBefore">
<p>Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>{tg('TAG_NAME')}</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional text value used when <code>TAG_NAME</code> is empty.</p>
</Trans>
<Trans i18nKey="youCanUseAnyOfTheStandardMergeTagsBelow">
<p>You can use any of the standard merge tags below. In addition to that every custom field has its own merge tag. Check the fields of the list you are going to send to.</p>
</Trans>
</>
);
} else if (tagLanguage === TagLanguages.HBS) {
instructions = (
<>
<Trans>
<p>Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>{tg('TAG_NAME')}</code>. </p>
</Trans>
<Trans i18nKey="youCanUseAnyOfTheStandardMergeTagsBelow">
<p>You can use any of the standard merge tags below. In addition to that every custom field has its own merge tag. Check the fields of the list you are going to send to.</p>
</Trans>
<Trans>
<p>The whole message is interpreted as Handlebars template (see <a href="http://handlebarsjs.com/">http://handlebarsjs.com/</a>). You can use any Handlebars blocks and expressions
in the template. The merge tags form the root context of the Handlebars template.</p>
</Trans>
</>
);
}
return ( return (
<div> <div>
<AlignedRow> <AlignedRow>
@ -480,12 +549,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
label={t('mergeTagReference')}/> label={t('mergeTagReference')}/>
{owner.state.showMergeTagReference && {owner.state.showMergeTagReference &&
<div style={{marginTop: '15px'}}> <div style={{marginTop: '15px'}}>
<Trans i18nKey="mergeTagsAreTagsThatAreReplacedBefore"> {instructions}
<p>Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>[TAG_NAME]</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional text value used when <code>TAG_NAME</code> is empty.</p>
</Trans>
<Trans i18nKey="youCanUseAnyOfTheStandardMergeTagsBelow">
<p>You can use any of the standard merge tags below. In addition to that every custom field has its own merge tag. Check the fields of the list you are going to send to.</p>
</Trans>
<table className="table table-bordered table-condensed table-striped"> <table className="table table-bordered table-condensed table-striped">
<thead> <thead>
<tr> <tr>
@ -500,7 +564,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
<tbody> <tbody>
<tr> <tr>
<th scope="row"> <th scope="row">
[LINK_UNSUBSCRIBE] {tg('LINK_UNSUBSCRIBE')}
</th> </th>
<td> <td>
<Trans i18nKey="urlThatPointsToTheUnsubscribePage">URL that points to the unsubscribe page</Trans> <Trans i18nKey="urlThatPointsToTheUnsubscribePage">URL that points to the unsubscribe page</Trans>
@ -508,7 +572,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[LINK_PREFERENCES] {tg('LINK_PREFERENCES')}
</th> </th>
<td> <td>
<Trans i18nKey="urlThatPointsToThePreferencesPageOfThe">URL that points to the preferences page of the subscriber</Trans> <Trans i18nKey="urlThatPointsToThePreferencesPageOfThe">URL that points to the preferences page of the subscriber</Trans>
@ -516,7 +580,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[LINK_BROWSER] {tg('LINK_BROWSER')}
</th> </th>
<td> <td>
<Trans i18nKey="urlToPreviewTheMessageInABrowser">URL to preview the message in a browser</Trans> <Trans i18nKey="urlToPreviewTheMessageInABrowser">URL to preview the message in a browser</Trans>
@ -524,7 +588,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[EMAIL] {tg('EMAIL')}
</th> </th>
<td> <td>
<Trans i18nKey="emailAddress-1">Email address</Trans> <Trans i18nKey="emailAddress-1">Email address</Trans>
@ -532,7 +596,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[SUBSCRIPTION_ID] {tg('SUBSCRIPTION_ID')}
</th> </th>
<td> <td>
<Trans i18nKey="uniqueIdThatIdentifiesTheRecipient">Unique ID that identifies the recipient</Trans> <Trans i18nKey="uniqueIdThatIdentifiesTheRecipient">Unique ID that identifies the recipient</Trans>
@ -540,7 +604,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[LIST_ID] {tg('LIST_ID')}
</th> </th>
<td> <td>
<Trans i18nKey="uniqueIdThatIdentifiesTheListUsedForThis">Unique ID that identifies the list used for this campaign</Trans> <Trans i18nKey="uniqueIdThatIdentifiesTheListUsedForThis">Unique ID that identifies the list used for this campaign</Trans>
@ -548,7 +612,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[CAMPAIGN_ID] {tg('CAMPAIGN_ID')}
</th> </th>
<td> <td>
<Trans i18nKey="uniqueIdThatIdentifiesCurrentCampaign">Unique ID that identifies current campaign</Trans> <Trans i18nKey="uniqueIdThatIdentifiesCurrentCampaign">Unique ID that identifies current campaign</Trans>
@ -573,7 +637,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
<tbody> <tbody>
<tr> <tr>
<th scope="row"> <th scope="row">
[RSS_ENTRY_TITLE] {tg('RSS_ENTRY_TITLE')}
</th> </th>
<td> <td>
<Trans i18nKey="rssEntryTitle">RSS entry title</Trans> <Trans i18nKey="rssEntryTitle">RSS entry title</Trans>
@ -581,7 +645,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[RSS_ENTRY_DATE] {tg('RSS_ENTRY_DATE')}
</th> </th>
<td> <td>
<Trans i18nKey="rssEntryDate">RSS entry date</Trans> <Trans i18nKey="rssEntryDate">RSS entry date</Trans>
@ -589,7 +653,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[RSS_ENTRY_LINK] {tg('RSS_ENTRY_LINK')}
</th> </th>
<td> <td>
<Trans i18nKey="rssEntryLink">RSS entry link</Trans> <Trans i18nKey="rssEntryLink">RSS entry link</Trans>
@ -597,7 +661,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[RSS_ENTRY_CONTENT] {tg('RSS_ENTRY_CONTENT')}
</th> </th>
<td> <td>
<Trans i18nKey="contentOfAnRssEntry">Content of an RSS entry</Trans> <Trans i18nKey="contentOfAnRssEntry">Content of an RSS entry</Trans>
@ -605,7 +669,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[RSS_ENTRY_SUMMARY] {tg('RSS_ENTRY_SUMMARY')}
</th> </th>
<td> <td>
<Trans i18nKey="rssEntrySummary">RSS entry summary</Trans> <Trans i18nKey="rssEntrySummary">RSS entry summary</Trans>
@ -613,7 +677,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr> </tr>
<tr> <tr>
<th scope="row"> <th scope="row">
[RSS_ENTRY_IMAGE_URL] {tg('RSS_ENTRY_IMAGE_URL')}
</th> </th>
<td> <td>
<Trans i18nKey="rssEntryImageUrl">RSS entry image URL</Trans> <Trans i18nKey="rssEntryImageUrl">RSS entry image URL</Trans>

View file

@ -18,13 +18,14 @@ import {
withFormErrorHandlers withFormErrorHandlers
} from '../../lib/form'; } from '../../lib/form';
import {withErrorHandling} from '../../lib/error-handling'; import {withErrorHandling} from '../../lib/error-handling';
import {NamespaceSelect, validateNamespace} from '../../lib/namespace'; import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../../lib/namespace';
import {DeleteModalDialog} from "../../lib/modals"; import {DeleteModalDialog} from "../../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import {getMJMLSample, getVersafix} from "../../../../shared/mosaico-templates"; import {getMJMLSample, getVersafix} from "../../../../shared/mosaico-templates";
import {getTemplateTypes, getTemplateTypesOrder} from "./helpers"; import {getTemplateTypes, getTemplateTypesOrder} from "./helpers";
import {withComponentMixins} from "../../lib/decorator-helpers"; import {withComponentMixins} from "../../lib/decorator-helpers";
import styles from "../../lib/styles.scss"; import styles from "../../lib/styles.scss";
import {getTagLanguages} from "../helpers";
@withComponentMixins([ @withComponentMixins([
withTranslation, withTranslation,
@ -38,6 +39,7 @@ export default class CUD extends Component {
super(props); super(props);
this.templateTypes = getTemplateTypes(props.t); this.templateTypes = getTemplateTypes(props.t);
this.tagLanguages = getTagLanguages(props.t);
this.typeOptions = []; this.typeOptions = [];
for (const type of getTemplateTypesOrder()) { for (const type of getTemplateTypesOrder()) {
@ -55,7 +57,8 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
wizard: PropTypes.string, wizard: PropTypes.string,
entity: PropTypes.object entity: PropTypes.object,
permissions: PropTypes.object
} }
getFormValuesMutator(data) { getFormValuesMutator(data) {
@ -63,8 +66,16 @@ export default class CUD extends Component {
} }
submitFormValuesMutator(data) { submitFormValuesMutator(data) {
const wizard = this.props.wizard;
if (wizard === 'versafix') {
data.html = getVersafix(data.tag_language);
} else if (wizard === 'mjml-sample') {
data.mjml = getMJMLSample(data.tag_language);
}
this.templateTypes[data.type].beforeSave(this, data); this.templateTypes[data.type].beforeSave(this, data);
return filterData(data, ['name', 'description', 'type', 'data', 'namespace']); return filterData(data, ['name', 'description', 'type', 'tag_language', 'data', 'namespace']);
} }
componentDidMount() { componentDidMount() {
@ -78,26 +89,27 @@ export default class CUD extends Component {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: '', description: '',
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
type: 'html', type: 'html',
html: getVersafix() tag_language: mailtrainConfig.tagLanguages[0]
}); });
} else if (wizard === 'mjml-sample') { } else if (wizard === 'mjml-sample') {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: '', description: '',
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
type: 'mjml', type: 'mjml',
mjml: getMJMLSample() tag_language: mailtrainConfig.tagLanguages[0]
}); });
} else { } else {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: '', description: '',
namespace: mailtrainConfig.user.namespace, namespace: getDefaultNamespace(this.props.permissions),
type: 'html', type: 'html',
tag_language: mailtrainConfig.tagLanguages[0],
html: '' html: ''
}); });
} }
@ -119,6 +131,12 @@ export default class CUD extends Component {
state.setIn(['type', 'error'], null); state.setIn(['type', 'error'], null);
} }
if (!state.getIn(['tag_language', 'value'])) {
state.setIn(['tag_language', 'error'], t('Tag language must be selected'));
} else {
state.setIn(['tag_language', 'error'], null);
}
validateNamespace(t, state); validateNamespace(t, state);
} }
@ -169,6 +187,11 @@ export default class CUD extends Component {
const typeKey = this.getFormValue('type'); const typeKey = this.getFormValue('type');
const tagLanguageOptions = [];
for (const key of mailtrainConfig.tagLanguages) {
tagLanguageOptions.push({key, label: this.tagLanguages[key].name});
}
return ( return (
<div> <div>
{canDelete && {canDelete &&
@ -194,6 +217,9 @@ export default class CUD extends Component {
: :
<Dropdown id="type" label={t('type')} options={this.typeOptions}/> <Dropdown id="type" label={t('type')} options={this.typeOptions}/>
} }
<Dropdown id="tag_language" label={t('Tag language')} options={tagLanguageOptions}/>
<NamespaceSelect/> <NamespaceSelect/>
{isEdit && typeKey && this.templateTypes[typeKey].getForm(this)} {isEdit && typeKey && this.templateTypes[typeKey].getForm(this)}

View file

@ -4,13 +4,14 @@ import React, {Component} from 'react';
import {withTranslation} from '../../lib/i18n'; import {withTranslation} from '../../lib/i18n';
import {ButtonDropdown, Icon} from '../../lib/bootstrap-components'; import {ButtonDropdown, Icon} from '../../lib/bootstrap-components';
import {DropdownLink, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page'; import {DropdownLink, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling'; import {withErrorHandling} from '../../lib/error-handling';
import {Table} from '../../lib/table'; import {Table} from '../../lib/table';
import moment from 'moment'; import moment from 'moment';
import {getTemplateTypes} from './helpers'; import {getTemplateTypes} from './helpers';
import {checkPermissions} from "../../lib/permissions"; import {getTagLanguages} from '../helpers';
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals"; import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
import {withComponentMixins} from "../../lib/decorator-helpers"; import {withComponentMixins} from "../../lib/decorator-helpers";
import PropTypes from 'prop-types';
@withComponentMixins([ @withComponentMixins([
@ -24,43 +25,33 @@ export default class List extends Component {
super(props); super(props);
this.templateTypes = getTemplateTypes(props.t); this.templateTypes = getTemplateTypes(props.t);
this.tagLanguages = getTagLanguages(props.t);
this.state = {}; this.state = {};
tableRestActionDialogInit(this); tableRestActionDialogInit(this);
} }
@withAsyncErrorHandler static propTypes = {
async fetchPermissions() { permissions: PropTypes.object
const result = await checkPermissions({
createMosaicoTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createMosaicoTemplate']
}
});
this.setState({
createPermitted: result.data.createMosaicoTemplate
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
} }
render() { render() {
const t = this.props.t; const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createMosaicoTemplate;
const columns = [ const columns = [
{ data: 1, title: t('name') }, { data: 1, title: t('name') },
{ data: 2, title: t('description') }, { data: 2, title: t('description') },
{ data: 3, title: t('type'), render: data => this.templateTypes[data].typeName }, { data: 3, title: t('type'), render: data => this.templateTypes[data].typeName },
{ data: 4, title: t('created'), render: data => moment(data).fromNow() }, { data: 4, title: t('Tag language'), render: data => this.tagLanguages[data].name },
{ data: 5, title: t('namespace') }, { data: 5, title: t('created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('namespace') },
{ {
actions: data => { actions: data => {
const actions = []; const actions = [];
const perms = data[6]; const perms = data[7];
if (perms.includes('edit')) { if (perms.includes('edit')) {
actions.push({ actions.push({
@ -100,7 +91,7 @@ export default class List extends Component {
return ( return (
<div> <div>
{tableRestActionDialogRender(this)} {tableRestActionDialogRender(this)}
{this.state.createPermitted && {createPermitted &&
<Toolbar> <Toolbar>
<ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" label={t('createMosaicoTemplate')}> <ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" label={t('createMosaicoTemplate')}>
<DropdownLink to="/templates/mosaico/create">{t('blank')}</DropdownLink> <DropdownLink to="/templates/mosaico/create">{t('blank')}</DropdownLink>

View file

@ -20,9 +20,7 @@ export function getTemplateTypes(t) {
return owner.getFormValue('mjml') || ''; return owner.getFormValue('mjml') || '';
} }
function generateHtmlFromMjml(owner) { function generateHtmlFromMjml(mjml) {
const mjml = getMjml(owner);
try { try {
const res = mjml2html(mjml); const res = mjml2html(mjml);
return res.html; return res.html;
@ -88,7 +86,7 @@ export function getTemplateTypes(t) {
typeName: t('mjml'), typeName: t('mjml'),
getForm: owner => ( getForm: owner => (
<> <>
<ContentModalDialog visible={!!owner.state.exportModalVisible} title={t('html')} getContentAsync={async () => generateHtmlFromMjml(owner)} onHide={() => setExportModalVisibility(owner, false)}/> <ContentModalDialog visible={!!owner.state.exportModalVisible} title={t('html')} getContentAsync={async () => generateHtmlFromMjml(getMjml(owner))} onHide={() => setExportModalVisibility(owner, false)}/>
<ACEEditor id="mjml" height="700px" mode="xml" label={t('templateContent')}/> <ACEEditor id="mjml" height="700px" mode="xml" label={t('templateContent')}/>
</> </>
), ),
@ -98,7 +96,7 @@ export function getTemplateTypes(t) {
beforeSave: (owner, data) => { beforeSave: (owner, data) => {
data.data = { data.data = {
mjml: data.mjml, mjml: data.mjml,
html: generateHtmlFromMjml(owner) html: generateHtmlFromMjml(data.mjml)
}; };
clearBeforeSend(data); clearBeforeSend(data);

View file

@ -9,14 +9,29 @@ import Files from "../lib/files";
import MosaicoCUD from './mosaico/CUD'; import MosaicoCUD from './mosaico/CUD';
import MosaicoList from './mosaico/List'; import MosaicoList from './mosaico/List';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"; import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) { function getMenus(t) {
return { return {
'templates': { 'templates': {
title: t('templates'), title: t('templates'),
link: '/templates', link: '/templates',
panelComponent: TemplatesList, checkPermissions: {
createTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createTemplate']
},
createMosaicoTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createMosaicoTemplate']
},
viewMosaicoTemplate: {
entityTypeId: 'mosaicoTemplate',
requiredOperations: ['view']
},
...namespaceCheckPermissions('createTemplate')
},
panelRender: props => <TemplatesList permissions={props.permissions}/>,
children: { children: {
':templateId([0-9]+)': { ':templateId([0-9]+)': {
title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}), title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}),
@ -29,7 +44,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/templates/${params.templateId}/edit`, link: params => `/templates/${params.templateId}/edit`,
visible: resolved => resolved.template.permissions.includes('edit'), visible: resolved => resolved.template.permissions.includes('edit'),
panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} setPanelInFullScreen={props.setPanelInFullScreen} /> panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} permissions={props.permissions} setPanelInFullScreen={props.setPanelInFullScreen} />
}, },
files: { files: {
title: t('files'), title: t('files'),
@ -47,12 +62,15 @@ function getMenus(t) {
}, },
create: { create: {
title: t('create'), title: t('create'),
panelRender: props => <TemplatesCUD action="create" /> panelRender: props => <TemplatesCUD action="create" permissions={props.permissions} />
}, },
mosaico: { mosaico: {
title: t('mosaicoTemplates'), title: t('mosaicoTemplates'),
link: '/templates/mosaico', link: '/templates/mosaico',
panelComponent: MosaicoList, checkPermissions: {
...namespaceCheckPermissions('createMosaicoTemplate')
},
panelRender: props => <MosaicoList permissions={props.permissions}/>,
children: { children: {
':mosaiceTemplateId([0-9]+)': { ':mosaiceTemplateId([0-9]+)': {
title: resolved => t('mosaicoTemplateName', {name: ellipsizeBreadcrumbLabel(resolved.mosaicoTemplate.name)}), title: resolved => t('mosaicoTemplateName', {name: ellipsizeBreadcrumbLabel(resolved.mosaicoTemplate.name)}),
@ -65,7 +83,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/templates/mosaico/${params.mosaiceTemplateId}/edit`, link: params => `/templates/mosaico/${params.mosaiceTemplateId}/edit`,
visible: resolved => resolved.mosaicoTemplate.permissions.includes('edit'), visible: resolved => resolved.mosaicoTemplate.permissions.includes('edit'),
panelRender: props => <MosaicoCUD action={props.match.params.action} entity={props.resolved.mosaicoTemplate} /> panelRender: props => <MosaicoCUD action={props.match.params.action} entity={props.resolved.mosaicoTemplate} permissions={props.permissions}/>
}, },
files: { files: {
title: t('files'), title: t('files'),
@ -90,7 +108,7 @@ function getMenus(t) {
create: { create: {
title: t('create'), title: t('create'),
extraParams: [':wizard?'], extraParams: [':wizard?'],
panelRender: props => <MosaicoCUD action="create" wizard={props.match.params.wizard} /> panelRender: props => <MosaicoCUD action="create" wizard={props.match.params.wizard} permissions={props.permissions} />
} }
} }
} }

View file

@ -19,7 +19,7 @@ import {withErrorHandling} from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors'; import interoperableErrors from '../../../shared/interoperable-errors';
import passwordValidator from '../../../shared/password-validator'; import passwordValidator from '../../../shared/password-validator';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {NamespaceSelect, validateNamespace} from '../lib/namespace'; import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals"; import {DeleteModalDialog} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
@ -49,7 +49,8 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
entity: PropTypes.object entity: PropTypes.object,
permissions: PropTypes.object
} }
getFormValuesMutator(data) { getFormValuesMutator(data) {
@ -71,7 +72,8 @@ export default class CUD extends Component {
email: '', email: '',
password: '', password: '',
password2: '', password2: '',
namespace: mailtrainConfig.user.namespace namespace: getDefaultNamespace(this.props.permissions),
role: null
}); });
} }
} }

View file

@ -5,12 +5,17 @@ import CUD from './CUD';
import List from './List'; import List from './List';
import UserShares from '../shares/UserShares'; import UserShares from '../shares/UserShares';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"; import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
import MosaicoCUD from "../templates/mosaico/CUD";
function getMenus(t) { function getMenus(t) {
return { return {
'users': { 'users': {
title: t('users'), title: t('users'),
link: '/users', link: '/users',
checkPermissions: {
...namespaceCheckPermissions('manageUsers')
},
panelComponent: List, panelComponent: List,
children: { children: {
':userId([0-9]+)': { ':userId([0-9]+)': {
@ -23,7 +28,7 @@ function getMenus(t) {
':action(edit|delete)': { ':action(edit|delete)': {
title: t('edit'), title: t('edit'),
link: params => `/users/${params.userId}/edit`, link: params => `/users/${params.userId}/edit`,
panelRender: props => <CUD action={props.match.params.action} entity={props.resolved.user} /> panelRender: props => <CUD action={props.match.params.action} entity={props.resolved.user} permissions={props.permissions} />
}, },
shares: { shares: {
title: t('shares'), title: t('shares'),
@ -34,7 +39,7 @@ function getMenus(t) {
}, },
create: { create: {
title: t('create'), title: t('create'),
panelRender: props => <CUD action="create" /> panelRender: props => <CUD action="create" permissions={props.permissions} />
}, },
} }
} }

View file

@ -199,11 +199,10 @@ textarea {
/* --- Other ------------- */ /* --- Other ------------- */
.help-block { .option-help-block {
display: block; display: block;
font-size: .9em; margin-left: 3px;
line-height: 1; margin-bottom: 4px;
color: #999999;
} }
form a { form a {

View file

@ -100,6 +100,7 @@
"customTemplateEditor": "Custom template editor", "customTemplateEditor": "Custom template editor",
"save": "Save", "save": "Save",
"saveAndLeave": "Save and leave", "saveAndLeave": "Save and leave",
"copy": "Copy",
"saveAndGoToStatus": "Save and go to status", "saveAndGoToStatus": "Save and go to status",
"testSend": "Test send", "testSend": "Test send",
"createRegularCampaign": "Create Regular Campaign", "createRegularCampaign": "Create Regular Campaign",
@ -923,6 +924,7 @@
"editTemplate": "Edit Template", "editTemplate": "Edit Template",
"createTemplate": "Create Template", "createTemplate": "Create Template",
"cloneFromAnExistingTemplate": "Clone from an existing template", "cloneFromAnExistingTemplate": "Clone from an existing template",
"cloneFromAnExistingCustomForms": "Clone from an existing custom forms",
"mosaico": "Mosaico", "mosaico": "Mosaico",
"templateContentHtml": "Template content (HTML)", "templateContentHtml": "Template content (HTML)",
"mosaicoTemplateDesigner": "Mosaico Template Designer", "mosaicoTemplateDesigner": "Mosaico Template Designer",

View file

@ -76,10 +76,10 @@
"forcesTheRssFeedCheckToImmediatelyCheck": "Forces the RSS feed check to immediately check the campaign with the given CID (in :campaignCid). It works only for RSS campaigns.", "forcesTheRssFeedCheckToImmediatelyCheck": "Forces the RSS feed check to immediately check the campaign with the given CID (in :campaignCid). It works only for RSS campaigns.",
"sendTransactionalEmail": "Send transactional email", "sendTransactionalEmail": "Send transactional email",
"sendSingleEmailByTemplateWithGiven": "Send single email by template with given templateId", "sendSingleEmailByTemplateWithGiven": "Send single email by template with given templateId",
"idOfConfigurationUsedToCreateMailer": "ID of configuration used to create mailer instance", "idOfConfigurationUsedToCreateMailer": "ID of configuration used to create mailer instance. If omitted, the default system send configuration is used.",
"subject": "Subject", "subject": "Subject",
"dataPassedToTemplateWhenCompilingWith": "Data passed to template when compiling with Handlebars", "dataPassedToTemplateWhenCompilingWith": "Data passed to template when compiling with Handlebars",
"mapOfTemplatesubjectVariablesToReplace": "Map of template/subject variables to replace", "mapOfTemplatesubjectVariablesToReplace": "Map of template variables to replace",
"apiResponseIsAJsonStructureWithErrorAnd": "API response is a JSON structure with <1>error</1> and <3>data</3> properties. If the response <5>error</5> has a value set then the request failed.", "apiResponseIsAJsonStructureWithErrorAnd": "API response is a JSON structure with <1>error</1> and <3>data</3> properties. If the response <5>error</5> has a value set then the request failed.",
"youNeedToDefineProperContentTypeWhen": "You need to define proper <1>Content-Type</1> when making a request. You can either use <3>application/x-www-form-urlencoded</3> for normal form data or <5>application/json</5> for a JSON payload. Using <7>multipart/form-data</7> is not supported.", "youNeedToDefineProperContentTypeWhen": "You need to define proper <1>Content-Type</1> when making a request. You can either use <3>application/x-www-form-urlencoded</3> for normal form data or <5>application/json</5> for a JSON payload. Using <7>multipart/form-data</7> is not supported.",
"emailMustNotBeEmpty-1": "Email must not be empty", "emailMustNotBeEmpty-1": "Email must not be empty",
@ -100,6 +100,7 @@
"customTemplateEditor": "Custom template editor", "customTemplateEditor": "Custom template editor",
"save": "Save", "save": "Save",
"saveAndLeave": "Save and leave", "saveAndLeave": "Save and leave",
"copy": "Copy",
"saveAndGoToStatus": "Save and go to status", "saveAndGoToStatus": "Save and go to status",
"testSend": "Test send", "testSend": "Test send",
"createRegularCampaign": "Create Regular Campaign", "createRegularCampaign": "Create Regular Campaign",
@ -923,6 +924,7 @@
"editTemplate": "Edit Template", "editTemplate": "Edit Template",
"createTemplate": "Create Template", "createTemplate": "Create Template",
"cloneFromAnExistingTemplate": "Clone from an existing template", "cloneFromAnExistingTemplate": "Clone from an existing template",
"cloneFromAnExistingCustomForms": "Clone from existing custom forms",
"mosaico": "Mosaico", "mosaico": "Mosaico",
"templateContentHtml": "Template content (HTML)", "templateContentHtml": "Template content (HTML)",
"mosaicoTemplateDesigner": "Mosaico Template Designer", "mosaicoTemplateDesigner": "Mosaico Template Designer",

View file

@ -105,6 +105,7 @@
"save": "Save", "save": "Save",
"saveAndLeave": "Save and leave", "saveAndLeave": "Save and leave",
"saveAndLeave - TODO: update line above and then delete this line to mark that the translation has been fixed": "Save and leave", "saveAndLeave - TODO: update line above and then delete this line to mark that the translation has been fixed": "Save and leave",
"copy": "Copiar",
"saveAndGoToStatus": "Save and go to status", "saveAndGoToStatus": "Save and go to status",
"saveAndGoToStatus - TODO: update line above and then delete this line to mark that the translation has been fixed": "Save and go to status", "saveAndGoToStatus - TODO: update line above and then delete this line to mark that the translation has been fixed": "Save and go to status",
"testSend": "Test send", "testSend": "Test send",
@ -992,8 +993,8 @@
"templateDeleted": "Template deleted", "templateDeleted": "Template deleted",
"editTemplate": "Edit Template", "editTemplate": "Edit Template",
"createTemplate": "Create Template", "createTemplate": "Create Template",
"cloneFromAnExistingTemplate": "Clone from an existing template", "cloneFromAnExistingTemplate": "Clonar a partir de templates ya existentes",
"cloneFromAnExistingTemplate - TODO: update line above and then delete this line to mark that the translation has been fixed": "Clone from an existing template", "cloneFromAnExistingCustomForms": "Clonar a partir de formularios personalizados ya existentes",
"mosaico": "Mosaico", "mosaico": "Mosaico",
"templateContentHtml": "Template content (HTML)", "templateContentHtml": "Template content (HTML)",
"mosaicoTemplateDesigner": "Mosaico Template Designer", "mosaicoTemplateDesigner": "Mosaico Template Designer",

View file

@ -105,6 +105,8 @@
"save": "Salvar", "save": "Salvar",
"saveAndLeave": "Salvar e sair", "saveAndLeave": "Salvar e sair",
"saveAndLeave - TODO: update line above and then delete this line to mark that the translation has been fixed": "Save and leave", "saveAndLeave - TODO: update line above and then delete this line to mark that the translation has been fixed": "Save and leave",
"copy": "Copia",
"copy - TODO: update line above and then delete this line to mark that the translation has been fixed": "Copy",
"saveAndGoToStatus": "Save and go to status", "saveAndGoToStatus": "Save and go to status",
"saveAndGoToStatus - TODO: update line above and then delete this line to mark that the translation has been fixed": "Save and go to status", "saveAndGoToStatus - TODO: update line above and then delete this line to mark that the translation has been fixed": "Save and go to status",
"testSend": "Testar envio", "testSend": "Testar envio",
@ -994,6 +996,8 @@
"createTemplate": "Criar modelo", "createTemplate": "Criar modelo",
"cloneFromAnExistingTemplate": "Clone from an existing template", "cloneFromAnExistingTemplate": "Clone from an existing template",
"cloneFromAnExistingTemplate - TODO: update line above and then delete this line to mark that the translation has been fixed": "Clone from an existing template", "cloneFromAnExistingTemplate - TODO: update line above and then delete this line to mark that the translation has been fixed": "Clone from an existing template",
"cloneFromAnExistingCustomForms": "Clone from an existing custom forms",
"cloneFromAnExistingCustomForms - TODO: update line above and then delete this line to mark that the translation has been fixed": "Clone from an existing custom forms",
"mosaico": "Mosaico", "mosaico": "Mosaico",
"templateContentHtml": "Conteúdo do modelo (HTML)", "templateContentHtml": "Conteúdo do modelo (HTML)",
"mosaicoTemplateDesigner": "Editor do Modelo Mosaico", "mosaicoTemplateDesigner": "Editor do Modelo Mosaico",

@ -1 +1 @@
Subproject commit ec89af43120f95dcf7f14dc92a2b3811bf50d7e8 Subproject commit 052fbff6e4eaba52eee2f984f2b8d947ebfa7298

View file

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('./lib/config');
const log = require('./lib/log'); const log = require('./lib/log');
const express = require('express'); const express = require('express');

View file

@ -36,6 +36,11 @@ editors:
- ckeditor4 - ckeditor4
- codeeditor - codeeditor
# Enabled tag languages
tagLanguages:
- simple # e.g. [FIRST_NAME] - this the style of merge tags found in Mailtrain v1
- hbs # e.g. {{#if FIRST_NAME}}Hello {{firstName}}!{{else}}Hello!{{/if}} - this syntax uses Handlebars templating language (http://handlebarsjs.com)
# Default language to use # Default language to use
defaultLanguage: en-US defaultLanguage: en-US
@ -165,6 +170,23 @@ queue:
# How many parallel sender processes to spawn # How many parallel sender processes to spawn
processes: 2 processes: 2
# For how long (in seconds) to try to send an email before Mailtrain stops to trying. An email can normally
# be sent out almost immediately. However if the send configuration is not correct or the mail server is not reachable,
# Mailtrain will keep retrying until the email expires.
# Due to Mailtrain's internal timeouts, the values should be at least 60 seconds.
retention:
# Regular and RSS campaign. Once this expires, the campaign is considered finished. The remaining recipients
# are included in the set of those recipients to whom the message would be delivered if the campaign is again started.
campaign: 86400 # 1 day
# Triggered campaign. Once this expires, the message gets discarded.
triggered: 86400 # 1 day
# Test send (in campaign or template)
test: 300 # 5 minutes
# Subscription and password reset related emails
subscription: 300 # 5 minutes
# Transactional emails sent via API (i.e. /templates/:templateId/send)
apiTransactional: 3600 # 60 minutes
cors: cors:
# Allow subscription widgets to be embedded # Allow subscription widgets to be embedded
# origins: ['https://www.example.com'] # origins: ['https://www.example.com']
@ -235,7 +257,11 @@ seleniumWebDriver:
browser: phantomjs browser: phantomjs
roles: # The section below defines the definition of roles (permissions) to be used when no "roles" section is provided
# in custom config (typically production.yaml). If you want to extend rules provided below, add corresponding rules
# in "defaultRoles" section in custom config. If you want to define roles from scratch, create "roles" section in
# the custom config.
defaultRoles:
global: global:
master: master:
name: Global Master name: Global Master
@ -260,9 +286,9 @@ roles:
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createCampaign, manageUsers] permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createCampaign, manageUsers]
children: children:
sendConfiguration: [viewPublic, viewPrivate, edit, delete, share, sendWithoutOverrides, sendWithAllowedOverrides, sendWithAnyOverrides] sendConfiguration: [viewPublic, viewPrivate, edit, delete, share, sendWithoutOverrides, sendWithAllowedOverrides, sendWithAnyOverrides]
list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports] list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, viewTestSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports, send, sendToTestUsers]
customForm: [view, edit, delete, share] customForm: [view, edit, delete, share]
campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, viewStats, fetchRss] campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, sendToTestUsers, viewStats, fetchRss]
template: [view, edit, delete, share, viewFiles, manageFiles] template: [view, edit, delete, share, viewFiles, manageFiles]
report: [view, edit, delete, share, execute, viewContent, viewOutput] report: [view, edit, delete, share, execute, viewContent, viewOutput]
reportTemplate: [view, edit, delete, share, execute] reportTemplate: [view, edit, delete, share, execute]
@ -275,9 +301,9 @@ roles:
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createTemplate, createMosaicoTemplate, createCampaign] permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createTemplate, createMosaicoTemplate, createCampaign]
children: children:
sendConfiguration: [viewPublic, sendWithoutOverrides, sendWithAllowedOverrides] sendConfiguration: [viewPublic, sendWithoutOverrides, sendWithAllowedOverrides]
list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports] list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, viewTestSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports, send, sendToTestUsers]
customForm: [view, edit, delete, share] customForm: [view, edit, delete, share]
campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, viewStats, fetchRss] campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, sendToTestUsers, viewStats, fetchRss]
template: [view, edit, delete, share, viewFiles, manageFiles] template: [view, edit, delete, share, viewFiles, manageFiles]
report: [view, edit, delete, share, execute, viewContent, viewOutput] report: [view, edit, delete, share, execute, viewContent, viewOutput]
reportTemplate: [view, share, execute] reportTemplate: [view, share, execute]
@ -286,12 +312,24 @@ roles:
campaignsCreator: campaignsCreator:
name: Campaigns Creator name: Campaigns Creator
description: In the respective namespace, the user has all permissions for templates and campaigns. description: In the respective namespace, the user has all permissions to create and manage templates and campaigns. The user can also read public data about send configurations and use Mosaico templates in the namespace.
permissions: [view, createTemplate, createCampaign] permissions: [view, createTemplate, createCampaign]
children: children:
sendConfiguration: [viewPublic] sendConfiguration: [viewPublic]
campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, fetchRss] campaign: [view, edit, delete, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, sendToTestUsers, viewStats, fetchRss]
template: [view, edit, delete, share, viewFiles, manageFiles] template: [view, edit, delete, viewFiles, manageFiles]
mosaicoTemplate: [view, viewFiles]
namespace: [view, createTemplate, createCampaign]
campaignsViewer:
name: Campaigns Viewer
description: In the respective namespace, the user has permissions to view campaigns and templates in order to be able to replicate them.
permissions: [view, createTemplate, createCampaign]
children:
campaign: [view, viewFiles, viewAttachments, viewTriggers]
template: [view, viewFiles]
mosaicoTemplate: [view, viewFiles]
namespace: [view]
sendConfiguration: sendConfiguration:
master: master:
@ -301,17 +339,17 @@ roles:
campaignsCreator: campaignsCreator:
name: Campaigns Creator name: Campaigns Creator
description: The user can only use the send configuration in setting up a campaign. However, this gives no permission to send. description: The user can only use the send configuration in setting up a campaign. However, this gives no permission to send.
permissions: [viewPublic] permissions: [viewPublic, sendWithoutOverrides]
list: list:
master: master:
name: Master name: Master
description: All permissions description: All permissions
permissions: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports] permissions: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, viewTestSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports, send, sendToTestUsers]
campaignsCreator: campaignsCreator:
name: Campaigns Creator name: Campaigns Creator
description: The user can only use the list in setting up a campaign. However, this gives no permission to view subscriptions or to send to the list. description: The user can only use the list in setting up a campaign and to send email to test users. This gives no permission to view subscriptions or to send to the whole list.
permissions: [view, viewFields, viewSegments] permissions: [view, viewFields, viewSegments, viewTestSubscriptions, sendToTestUsers]
customForm: customForm:
master: master:
@ -323,11 +361,15 @@ roles:
master: master:
name: Master name: Master
description: All permissions description: All permissions
permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, viewStats, manageMessages, fetchRss] permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, sendToTestUsers, viewStats, manageMessages, fetchRss]
campaignsCreator: creator:
name: Campaigns Creator name: Creator
description: The user can setup the campaign but cannot send it. description: The user can setup the campaign but cannot send it.
permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, fetchRss] permissions: [view, edit, delete, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, sendToTestUsers, viewStats, fetchRss]
viewer:
name: Viewer
description: The user can view the campaign but cannot edit it or send it.
permissions: [view, viewFiles, viewAttachments, viewTriggers]
rssTrigger: rssTrigger:
name: RSS Campaign Trigger name: RSS Campaign Trigger
description: Allows triggering a fetch of an RSS campaign description: Allows triggering a fetch of an RSS campaign
@ -338,6 +380,10 @@ roles:
name: Master name: Master
description: All permissions description: All permissions
permissions: [view, edit, delete, share, viewFiles, manageFiles] permissions: [view, edit, delete, share, viewFiles, manageFiles]
viewer:
name: Viewer
description: The user can view the template but cannot edit it.
permissions: [view, viewFiles]
report: report:
master: master:
@ -356,5 +402,8 @@ roles:
name: Master name: Master
description: All permissions description: All permissions
permissions: [view, edit, delete, share, viewFiles, manageFiles] permissions: [view, edit, delete, share, viewFiles, manageFiles]
viewer:
name: Viewer
description: The user can use the Mosaico template, but cannot edit it or delete it.
permissions: [view, viewFiles]

View file

@ -1,32 +0,0 @@
'use strict';
const config = require('config');
const knex = require('./lib/knex');
const moment = require('moment');
const shortid = require('shortid');
async function run() {
// const info = await knex('subscription__1').columnInfo();
// console.log(info);
// const ts = moment().toDate();
const ts = new Date(Date.now());
console.log(ts);
const cid = shortid.generate();
await knex('subscription__1')
.insert({
email: cid,
cid,
custom_date_mmddyy_rjkeojrzz: ts
});
const row = await knex('subscription__1').select(['id', 'created', 'custom_date_mmddyy_rjkeojrzz']).where('cid', cid).first();
// const row = await knex('subscription__1').where('id', 2).first();
console.log(row);
}
run();

View file

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('./lib/config');
const log = require('./lib/log'); const log = require('./lib/log');
const appBuilder = require('./app-builder'); const appBuilder = require('./app-builder');
const translate = require('./lib/translate'); const translate = require('./lib/translate');

View file

@ -1,20 +1,34 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('./config');
const fork = require('./fork').fork; const fork = require('./fork').fork;
const log = require('./log'); const log = require('./log');
const path = require('path'); const path = require('path');
const fs = require('fs-extra') const fs = require('fs-extra');
const crypto = require('crypto'); const crypto = require('crypto');
const bluebird = require('bluebird'); const bluebird = require('bluebird');
let zoneMtaProcess; let zoneMtaProcess = null;
const zoneMtaDir = path.join(__dirname, '..', '..', 'zone-mta'); const zoneMtaDir = path.join(__dirname, '..', '..', 'zone-mta');
const zoneMtaBuiltingConfig = path.join(zoneMtaDir, 'config', 'builtin-zonemta.json'); const zoneMtaBuiltingConfig = path.join(zoneMtaDir, 'config', 'builtin-zonemta.json');
const password = process.env.BUILTIN_ZONE_MTA_PASSWORD || crypto.randomBytes(20).toString('hex').toLowerCase(); const password = process.env.BUILTIN_ZONE_MTA_PASSWORD || crypto.randomBytes(20).toString('hex').toLowerCase();
let restartCount = 0;
let lastRestartCount = 0;
let restartBackoffIdx = 0;
const restartBackoff = [0, 30, 60, 300]; // in seconds
setInterval(() => {
if (restartCount === lastRestartCount) {
restartBackoffIdx = 0;
}
lastRestartCount = restartCount;
}, 300000 /* 5 mins */);
function getUsername() { function getUsername() {
return 'mailtrain'; return 'mailtrain';
} }
@ -119,11 +133,14 @@ async function createConfig() {
await fs.writeFile(zoneMtaBuiltingConfig, JSON.stringify(cnf, null, 2)); await fs.writeFile(zoneMtaBuiltingConfig, JSON.stringify(cnf, null, 2));
} }
function spawn(callback) { function restart(callback) {
if (config.builtinZoneMTA.enabled) { if (zoneMtaProcess) return callback();
createConfig().then(() => { if (restartCount === 0) {
log.info('ZoneMTA', 'Starting built-in Zone MTA process'); log.info('ZoneMTA', 'Starting built-in Zone MTA process');
} else {
log.info('ZoneMTA', `Restarting built-in Zone MTA process (restart count ${restartCount})`);
}
zoneMtaProcess = fork( zoneMtaProcess = fork(
path.join(zoneMtaDir, 'index.js'), path.join(zoneMtaDir, 'index.js'),
@ -138,17 +155,36 @@ function spawn(callback) {
if (msg) { if (msg) {
if (msg.type === 'zone-mta-started') { if (msg.type === 'zone-mta-started') {
log.info('ZoneMTA', 'ZoneMTA process started'); log.info('ZoneMTA', 'ZoneMTA process started');
if (callback) {
return callback(); return callback();
} else if (msg.type === 'entries-added') { } else {
senders.scheduleCheck(); return;
}
} }
} }
}); });
zoneMtaProcess.on('close', (code, signal) => { zoneMtaProcess.on('close', (code, signal) => {
log.error('ZoneMTA', 'ZoneMTA process exited with code %s signal %s', code, signal); log.error('ZoneMTA', 'ZoneMTA process exited with code %s signal %s', code, signal);
});
zoneMtaProcess = null;
restartCount += 1;
const backoffTimeout = restartBackoff[restartBackoffIdx] * 1000;
if (restartBackoffIdx < restartBackoff.length - 1) {
restartBackoffIdx += 1;
}
setTimeout(restart, backoffTimeout);
});
}
function spawn(callback) {
if (config.builtinZoneMTA.enabled) {
createConfig().then(() => {
restart(callback);
}).catch(err => callback(err)); }).catch(err => callback(err));
} else { } else {

View file

@ -1,472 +0,0 @@
'use strict';
const config = require('config');
const log = require('./log');
const mailers = require('./mailers');
const knex = require('./knex');
const subscriptions = require('../models/subscriptions');
const contextHelpers = require('./context-helpers');
const campaigns = require('../models/campaigns');
const templates = require('../models/templates');
const lists = require('../models/lists');
const fields = require('../models/fields');
const sendConfigurations = require('../models/send-configurations');
const links = require('../models/links');
const {CampaignSource, CampaignType} = require('../../shared/campaigns');
const {SubscriptionStatus} = require('../../shared/lists');
const tools = require('./tools');
const request = require('request-promise');
const files = require('../models/files');
const htmlToText = require('html-to-text');
const {getPublicUrl} = require('./urls');
const blacklist = require('../models/blacklist');
const libmime = require('libmime');
const shares = require('../models/shares');
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, contextHelpers.getAdminContext(), 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 = useVerp && !sendConfiguration.verp_disable_sender_header;
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);
await campaigns.enforceSendPermissionTx(tx, context, campaign);
} else {
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', sendConfigurationId, 'sendWithoutOverrides');
// 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, 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: list.to_name === null ? undefined : tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false),
address: subscriptionGrouped.email
},
sender: useVerpSenderHeader ? campaignAddress + '@' + sendConfiguration.verp_hostname : false,
envelope: 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),
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
this.listsFieldsGrouped = new Map(); // listId -> fieldsGrouped
this.attachments = [];
await knex.transaction(async tx => {
if (settings.campaignCid) {
this.campaign = await campaigns.rawGetByTx(tx, 'cid', settings.campaignCid);
} else {
this.campaign = await campaigns.rawGetByTx(tx, 'id', settings.campaignId);
}
const campaign = this.campaign;
this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.send_configuration, false, true);
for (const listSpec of campaign.lists) {
const list = await lists.getByIdTx(tx, contextHelpers.getAdminContext(), listSpec.list);
this.listsById.set(list.id, list);
this.listsByCid.set(list.cid, list);
this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
}
if (campaign.source === CampaignSource.TEMPLATE) {
this.template = await templates.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.data.sourceTemplate, false);
}
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', campaign.id, attachment.filename)
});
}
this.useVerp = config.verp.enabled && this.sendConfiguration.verp_hostname;
this.useVerpSenderHeader = this.useVerp && !this.sendConfiguration.verp_disable_sender_header;
});
}
async _getMessage(campaign, list, subscriptionGrouped, mergeTags, replaceDataImgs) {
let html = '';
let text = '';
let renderTags = false;
if (campaign.source === CampaignSource.URL) {
const form = tools.getMessageLinks(campaign, list, subscriptionGrouped);
for (const key in mergeTags) {
form[key] = mergeTags[key];
}
const response = await request.post({
uri: campaign.sourceUrl,
form,
resolveWithFullResponse: true
});
if (response.statusCode !== 200) {
throw new Error(`Received status code ${httpResponse.statusCode} from ${campaign.sourceUrl}`);
}
html = response.body;
text = '';
renderTags = false;
} else if (campaign.source === CampaignSource.CUSTOM || campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN || campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
html = campaign.data.sourceCustom.html;
text = campaign.data.sourceCustom.text;
renderTags = true;
} else if (campaign.source === CampaignSource.TEMPLATE) {
const template = this.template;
html = template.html;
text = template.text;
renderTags = true;
}
html = await links.updateLinks(campaign, list, subscriptionGrouped, mergeTags, html);
const attachments = this.attachments.slice();
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';
attachments.push({
path: dataUri,
cid
});
return prefix + 'cid:' + cid;
});
}
html = renderTags ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, html, true) : html;
text = (text || '').trim()
? (renderTags ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, text) : text)
: htmlToText.fromString(html, {wordwrap: 130});
return {
html,
text,
attachments
};
}
_getExtraTags(campaign) {
const tags = {};
if (campaign.type === CampaignType.RSS_ENTRY) {
const rssEntry = campaign.data.rssEntry;
tags['RSS_ENTRY_TITLE'] = rssEntry.title;
tags['RSS_ENTRY_DATE'] = rssEntry.date;
tags['RSS_ENTRY_LINK'] = rssEntry.link;
tags['RSS_ENTRY_CONTENT'] = rssEntry.content;
tags['RSS_ENTRY_SUMMARY'] = rssEntry.summary;
tags['RSS_ENTRY_IMAGE_URL'] = rssEntry.imageUrl;
tags['RSS_ENTRY_CUSTOM_TAGS'] = rssEntry.customTags;
}
return tags;
}
async getMessage(listCid, subscriptionCid) {
const list = this.listsByCid.get(listCid);
const subscriptionGrouped = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, subscriptionCid);
const flds = this.listsFieldsGrouped.get(list.id);
const campaign = this.campaign;
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
return await this._getMessage(campaign, list, subscriptionGrouped, mergeTags, false);
}
async sendMessageByEmail(listId, email) {
const subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), listId, email);
await this._sendMessage(listId, subscriptionGrouped);
}
async sendMessageBySubscriptionId(listId, subscriptionId) {
const subscriptionGrouped = await subscriptions.getById(contextHelpers.getAdminContext(), listId, subscriptionId);
await this._sendMessage(listId, subscriptionGrouped);
}
async _sendMessage(listId, subscriptionGrouped) {
const email = subscriptionGrouped.email;
if (await blacklist.isBlacklisted(email)) {
return;
}
const list = this.listsById.get(listId);
const flds = this.listsFieldsGrouped.get(list.id);
const campaign = this.campaign;
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
const encryptionKeys = [];
for (const fld of flds) {
if (fld.type === 'gpg' && mergeTags[fld.key]) {
encryptionKeys.push(mergeTags[fld.key].trim());
}
}
const sendConfiguration = this.sendConfiguration;
const {html, text, attachments} = await this._getMessage(campaign, list, subscriptionGrouped, mergeTags, true);
const campaignAddress = [campaign.cid, list.cid, subscriptionGrouped.cid].join('.');
let listUnsubscribe = null;
if (!list.listunsubscribe_disabled) {
listUnsubscribe = campaign.unsubscribe_url
? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, campaign.unsubscribe_url)
: getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscriptionGrouped.cid);
}
const mailer = await mailers.getOrCreateMailer(sendConfiguration.id);
await mailer.throttleWait();
const getOverridable = key => {
if (sendConfiguration[key + '_overridable'] && this.campaign[key + '_override'] !== null) {
return campaign[key + '_override'] || '';
} else {
return sendConfiguration[key] || '';
}
};
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: list.to_name === null ? undefined : tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, 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: listUnsubscribe
},
subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false),
html,
text,
attachments,
encryptionKeys
};
let status;
let response;
let responseId = null;
try {
const info = await mailer.sendMassMail(mail);
status = SubscriptionStatus.SUBSCRIBED;
log.verbose('CampaignSender', `response: ${info.response} messageId: ${info.messageId}`);
let match;
if ((match = info.response.match(/^250 Message queued as ([0-9a-f]+)$/))) {
/*
ZoneMTA
info.response: 250 Message queued as 1691ad7f7ae00080fd
info.messageId: <e65c9386-e899-7d01-b21e-ec03c3a9d9b4@sathyasai.org>
*/
response = info.response;
responseId = match[1];
} else if ((match = info.messageId.match(/^<([^>@]*)@.*amazonses\.com>$/))) {
/*
AWS SES
info.response: 0102016ad2244c0a-955492f2-9194-4cd1-bef9-70a45906a5a7-000000
info.messageId: <0102016ad2244c0a-955492f2-9194-4cd1-bef9-70a45906a5a7-000000@eu-west-1.amazonses.com>
*/
response = info.response;
responseId = match[1];
} else if (info.response.match(/^250 OK$/) && (match = info.messageId.match(/^<([^>]*)>$/))) {
/*
Postal Mail Server
info.response: 250 OK
info.messageId: <xxxxxxxxx@xxx.xx> (postal messageId)
*/
response = info.response;
responseId = match[1];
} else {
/*
Fallback - Mailtrain v1 behavior
*/
response = info.response || info.messageId;
responseId = response.split(/\s+/).pop();
}
await knex('campaigns').where('id', campaign.id).increment('delivered');
} catch (err) {
status = SubscriptionStatus.BOUNCED;
response = err.response || err.message;
await knex('campaigns').where('id', campaign.id).increment('delivered').increment('bounced');
}
const now = new Date();
if (campaign.type === CampaignType.REGULAR || campaign.type === CampaignType.RSS_ENTRY) {
await knex('campaign_messages').insert({
campaign: this.campaign.id,
list: list.id,
subscription: subscriptionGrouped.id,
send_configuration: sendConfiguration.id,
status,
response,
response_id: responseId,
updated: now
});
} else if (campaign.type = CampaignType.TRIGGERED) {
await knex('queued')
.where({
campaign: this.campaign.id,
list: list.id,
subscription: subscriptionGrouped.id
})
.del();
}
}
}
module.exports = CampaignSender;

View file

@ -1,7 +1,7 @@
'use strict'; 'use strict';
const passport = require('./passport'); const passport = require('./passport');
const config = require('config'); const config = require('./config');
const forms = require('../models/forms'); const forms = require('../models/forms');
const shares = require('../models/shares'); const shares = require('../models/shares');
const urls = require('./urls'); const urls = require('./urls');
@ -44,6 +44,7 @@ async function getAuthenticatedConfig(context) {
}, },
globalPermissions, globalPermissions,
editors: config.editors, editors: config.editors,
tagLanguages: config.tagLanguages,
mosaico: config.mosaico, mosaico: config.mosaico,
verpEnabled: config.verp.enabled, verpEnabled: config.verp.enabled,
reportsEnabled: config.reports.enabled, reportsEnabled: config.reports.enabled,

9
server/lib/config.js Normal file
View file

@ -0,0 +1,9 @@
"use strict";
const config = require('config');
if (!config.roles) {
config.roles = config.defaultRoles;
}
module.exports = config;

View file

@ -4,7 +4,7 @@
This module handles Mailtrain database initialization and upgrades This module handles Mailtrain database initialization and upgrades
*/ */
const config = require('config'); const config = require('./config');
const mysql = require('mysql'); const mysql = require('mysql');
const log = require('./log'); const log = require('./log');
const fs = require('fs'); const fs = require('fs');
@ -12,7 +12,7 @@ const pathlib = require('path');
const Handlebars = require('handlebars'); const Handlebars = require('handlebars');
const bluebird = require('bluebird'); const bluebird = require('bluebird');
const highestLegacySchemaVersion = 33; const highestLegacySchemaVersion = 34;
const mysqlConfig = { const mysqlConfig = {
multipleStatements: true multipleStatements: true

View file

@ -4,7 +4,6 @@ const knex = require('./knex');
const interoperableErrors = require('../../shared/interoperable-errors'); const interoperableErrors = require('../../shared/interoperable-errors');
const entitySettings = require('./entity-settings'); const entitySettings = require('./entity-settings');
const shares = require('../models/shares'); const shares = require('../models/shares');
const { enforce } = require('./helpers');
const defaultNoOfDependenciesReported = 20; const defaultNoOfDependenciesReported = 20;
@ -21,7 +20,7 @@ async function ensureNoDependencies(tx, context, id, depSpecs) {
if (depSpec.query) { if (depSpec.query) {
rows = await depSpec.query(tx).limit(defaultNoOfDependenciesReported + 1); rows = await depSpec.query(tx).limit(defaultNoOfDependenciesReported + 1);
} else if (depSpec.column) { } else if (depSpec.column) {
rows = await tx(entityType.entitiesTable).where(depSpec.column, id).select(['id', 'name']).limit(defaultNoOfDependenciesReported + 1); rows = await tx(entityType.entitiesTable).where(depSpec.column, id).forShare().select(['id', 'name']).limit(defaultNoOfDependenciesReported + 1);
} else if (depSpec.rows) { } else if (depSpec.rows) {
rows = await depSpec.rows(tx, defaultNoOfDependenciesReported + 1) rows = await depSpec.rows(tx, defaultNoOfDependenciesReported + 1)
} }
@ -32,16 +31,17 @@ async function ensureNoDependencies(tx, context, id, depSpecs) {
break; break;
} }
if (await shares.checkEntityPermissionTx(tx, context, depSpec.entityTypeId, row.id, 'view')) { if (depSpec.viewPermission && await shares.checkEntityPermissionTx(tx, context, depSpec.viewPermission.entityTypeId, depSpec.viewPermission.entityId, depSpec.viewPermission.requiredOperations)) {
deps.push({ deps.push({
entityTypeId: depSpec.entityTypeId, entityTypeId: depSpec.entityTypeId,
name: row.name, name: row.name,
link: entityType.clientLink(row.id) link: entityType.clientLink(row.id)
}); });
} else { } else if (!depSpec.viewPermission && await shares.checkEntityPermissionTx(tx, context, depSpec.entityTypeId, row.id, 'view')) {
deps.push({ deps.push({
entityTypeId: depSpec.entityTypeId, entityTypeId: depSpec.entityTypeId,
id: row.id name: row.name,
link: entityType.clientLink(row.id)
}); });
} }
} }

View file

@ -44,6 +44,7 @@ const entityTypes = {
}, },
attachment: { attachment: {
table: 'files_campaign_attachment', table: 'files_campaign_attachment',
inUseTable: 'files_campaign_attachment_usage',
permissions: { permissions: {
view: 'viewAttachments', view: 'viewAttachments',
manage: 'manageAttachments' manage: 'manageAttachments'

View file

@ -1,10 +1,14 @@
'use strict'; 'use strict';
const crypto = require('crypto');
module.exports = { module.exports = {
enforce, enforce,
cleanupFromPost, cleanupFromPost,
filterObject, filterObject,
castToInteger castToInteger,
normalizeEmail,
hashEmail
}; };
function enforce(condition, message) { function enforce(condition, message) {
@ -28,12 +32,30 @@ function filterObject(obj, allowedKeys) {
return result; return result;
} }
function castToInteger(id) { function castToInteger(id, msg) {
const val = parseInt(id); const val = parseInt(id);
if (!Number.isInteger(val)) { if (!Number.isInteger(val)) {
throw new Error('Invalid id'); throw new Error(msg || 'Invalid id');
} }
return val; return val;
} }
function normalizeEmail(email) {
const emailParts = email.split(/@/);
if (emailParts.length !== 2) {
return email;
}
const username = emailParts[0];
const domain = emailParts[1].toLowerCase();
return username + '@' + domain;
}
function hashEmail(email) {
return crypto.createHash('sha512').update(normalizeEmail(email)).digest("base64");
}

View file

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('./config');
const path = require('path'); const path = require('path');
const knexConstructor = require('knex'); const knexConstructor = require('knex');
@ -23,6 +23,12 @@ const knex = require('knex')({
//, debug: true //, debug: true
}); });
/*
This is to enable logging on mysql side:
SET GLOBAL general_log = 'ON';
SET GLOBAL general_log_file = '/tmp/mysql-all.log';
*/
module.exports = knex; module.exports = knex;

View file

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('./config');
const log = require('npmlog'); const log = require('npmlog');
log.level = config.log.level; log.level = config.log.level;

View file

@ -1,10 +1,8 @@
'use strict'; 'use strict';
const log = require('./log'); const log = require('./log');
const config = require('config'); const config = require('./config');
const Handlebars = require('handlebars');
const util = require('util');
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const aws = require('aws-sdk'); const aws = require('aws-sdk');
const openpgpEncrypt = require('nodemailer-openpgp').openpgpEncrypt; const openpgpEncrypt = require('nodemailer-openpgp').openpgpEncrypt;
@ -14,13 +12,21 @@ const builtinZoneMta = require('./builtin-zone-mta');
const contextHelpers = require('./context-helpers'); const contextHelpers = require('./context-helpers');
const settings = require('../models/settings'); const settings = require('../models/settings');
const tools = require('./tools');
const htmlToText = require('html-to-text');
const bluebird = require('bluebird'); const bluebird = require('bluebird');
const transports = new Map(); const transports = new Map();
class SendConfigurationError extends Error {
constructor(sendConfigurationId, ...args) {
super(...args);
this.sendConfigurationId = sendConfigurationId;
Error.captureStackTrace(this, SendConfigurationError);
}
}
async function getOrCreateMailer(sendConfigurationId) { async function getOrCreateMailer(sendConfigurationId) {
let sendConfiguration; let sendConfiguration;
@ -73,62 +79,26 @@ function _addDkimKeys(transport, mail) {
async function _sendMail(transport, mail, template) { async function _sendMail(transport, mail, template) {
_addDkimKeys(transport, mail); _addDkimKeys(transport, mail);
let tryCount = 0; try {
const trySend = (callback) => { return await transport.sendMailAsync(mail);
tryCount++;
transport.sendMail(mail, (err, info) => {
if (err) {
log.error('Mail', err);
if (err.responseCode && err.responseCode >= 400 && err.responseCode < 500 && tryCount <= 5) {
// temporary error, try again
log.verbose('Mail', 'Retrying after %s sec. ...', tryCount);
return setTimeout(trySend, tryCount * 1000);
}
return callback(err);
}
return callback(null, info);
});
};
const trySendAsync = bluebird.promisify(trySend); } catch (err) {
return await trySendAsync(); if ( (err.responseCode && err.responseCode >= 400 && err.responseCode < 500) ||
(err.code === 'ECONNECTION' && err.errno === 'ECONNREFUSED')
) {
throw new SendConfigurationError(transport.mailer.sendConfiguration.id, 'Cannot connect to service specified by send configuration ' + transport.mailer.sendConfiguration.id);
} }
async function _sendTransactionalMail(transport, mail, template) { throw err;
const sendConfiguration = transport.mailer.sendConfiguration; }
}
async function _sendTransactionalMail(transport, mail) {
if (!mail.headers) { if (!mail.headers) {
mail.headers = {}; mail.headers = {};
} }
mail.headers['X-Sending-Zone'] = 'transactional'; mail.headers['X-Sending-Zone'] = 'transactional';
mail.from = {
name: sendConfiguration.from_name,
address: sendConfiguration.from_email
};
const htmlRenderer = await tools.getTemplate(template.html, template.locale);
if (htmlRenderer) {
mail.html = htmlRenderer(template.data || {});
}
const preparedHtml = await tools.prepareHtml(mail.html);
if (preparedHtml) {
mail.html = preparedHtml;
}
const textRenderer = await tools.getTemplate(template.text, template.locale);
if (textRenderer) {
mail.text = textRenderer(template.data || {});
} else if (mail.html) {
mail.text = htmlToText.fromString(mail.html, {
wordwrap: 130
});
}
return await _sendMail(transport, mail); return await _sendMail(transport, mail);
} }
@ -218,6 +188,7 @@ async function _createTransport(sendConfiguration) {
} }
const transport = nodemailer.createTransport(transportOptions, config.nodemailer); const transport = nodemailer.createTransport(transportOptions, config.nodemailer);
transport.sendMailAsync = bluebird.promisify(transport.sendMail.bind(transport));
transport.use('stream', openpgpEncrypt({ transport.use('stream', openpgpEncrypt({
signingKey: configItems.pgpPrivateKey, signingKey: configItems.pgpPrivateKey,
@ -263,7 +234,7 @@ async function _createTransport(sendConfiguration) {
transport.mailer = { transport.mailer = {
sendConfiguration, sendConfiguration,
throttleWait: bluebird.promisify(throttleWait), throttleWait: bluebird.promisify(throttleWait),
sendTransactionalMail: async (mail, template) => await _sendTransactionalMail(transport, mail, template), sendTransactionalMail: async (mail) => await _sendTransactionalMail(transport, mail),
sendMassMail: async (mail, template) => await _sendMail(transport, mail) sendMassMail: async (mail, template) => await _sendMail(transport, mail)
}; };
@ -281,3 +252,4 @@ class MailerError extends Error {
module.exports.getOrCreateMailer = getOrCreateMailer; module.exports.getOrCreateMailer = getOrCreateMailer;
module.exports.invalidateMailer = invalidateMailer; module.exports.invalidateMailer = invalidateMailer;
module.exports.MailerError = MailerError; module.exports.MailerError = MailerError;
module.exports.SendConfigurationError = SendConfigurationError;

View file

@ -0,0 +1,746 @@
'use strict';
const config = require('./config');
const log = require('./log');
const mailers = require('./mailers');
const knex = require('./knex');
const subscriptions = require('../models/subscriptions');
const contextHelpers = require('./context-helpers');
const campaigns = require('../models/campaigns');
const templates = require('../models/templates');
const lists = require('../models/lists');
const fields = require('../models/fields');
const sendConfigurations = require('../models/send-configurations');
const links = require('../models/links');
const {CampaignSource, CampaignType} = require('../../shared/campaigns');
const {toNameTagLangauge} = require('../../shared/lists');
const {CampaignMessageStatus} = require('../../shared/campaigns');
const tools = require('./tools');
const htmlToText = require('html-to-text');
const request = require('request-promise');
const files = require('../models/files');
const {getPublicUrl} = require('./urls');
const blacklist = require('../models/blacklist');
const libmime = require('libmime');
const { enforce, hashEmail } = require('./helpers');
const senders = require('./senders');
const MessageType = {
REGULAR: 0,
TRIGGERED: 1,
TEST: 2,
SUBSCRIPTION: 3,
API_TRANSACTIONAL: 4
};
class MessageSender {
constructor() {
}
/*
Accepted combinations of settings:
Option #1
- settings.type in [MessageType.REGULAR, MessageType.TRIGGERED, MessageType.TEST]
- campaignCid / campaignId
- listId / listCid [optional if campaign is provided]
- sendConfigurationId [optional if campaign is provided]
- attachments [optional]
- renderedHtml + renderedText / html + text + tagLanguage [optional if campaign is provided]
- subject [optional if campaign is provided]
Option #2
- settings.type in [MessageType.SUBSCRIPTION, MessageType.API_TRANSACTIONAL]
- sendConfigurationId
- attachments [optional]
- renderedHtml + renderedText / html + text + tagLanguage
- subject
*/
async _init(settings) {
this.type = settings.type;
this.listsById = new Map(); // listId -> list
this.listsByCid = new Map(); // listCid -> list
this.listsFieldsGrouped = new Map(); // listId -> fieldsGrouped
await knex.transaction(async tx => {
if (this.type === MessageType.REGULAR || this.type === MessageType.TRIGGERED || this.type === MessageType.TEST) {
this.isMassMail = true;
if (settings.campaignCid) {
this.campaign = await campaigns.rawGetByTx(tx, 'cid', settings.campaignCid);
} else if (settings.campaignId) {
this.campaign = await campaigns.rawGetByTx(tx, 'id', settings.campaignId);
}
if (settings.sendConfigurationId) {
this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), settings.sendConfigurationId, false, true);
} else if (this.campaign && this.campaign.send_configuration) {
this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), this.campaign.send_configuration, false, true);
} else {
enforce(false);
}
this.useVerp = config.verp.enabled && this.sendConfiguration.verp_hostname;
this.useVerpSenderHeader = this.useVerp && !this.sendConfiguration.verp_disable_sender_header;
if (settings.listId) {
const list = await lists.getByIdTx(tx, contextHelpers.getAdminContext(), settings.listId);
this.listsById.set(list.id, list);
this.listsByCid.set(list.cid, list);
this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
} else if (settings.listCid) {
const list = await lists.getByCidTx(tx, contextHelpers.getAdminContext(), settings.listCid);
this.listsById.set(list.id, list);
this.listsByCid.set(list.cid, list);
this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
} else if (this.campaign && this.campaign.lists) {
for (const listSpec of this.campaign.lists) {
const list = await lists.getByIdTx(tx, contextHelpers.getAdminContext(), listSpec.list);
this.listsById.set(list.id, list);
this.listsByCid.set(list.cid, list);
this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
}
}
} else if (this.type === MessageType.SUBSCRIPTION || this.type === MessageType.API_TRANSACTIONAL) {
this.isMassMail = false;
this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), settings.sendConfigurationId, false, true);
} else {
enforce(false);
}
if (settings.attachments) {
this.attachments = settings.attachments;
} else if (this.campaign && this.campaign.id) {
const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', this.campaign.id);
this.attachments = [];
for (const attachment of attachments) {
this.attachments.push({
filename: attachment.originalname,
path: files.getFilePath('campaign', 'attachment', this.campaign.id, attachment.filename)
});
}
} else {
this.attachments = [];
}
if (settings.renderedHtml !== undefined) {
this.renderedHtml = settings.renderedHtml;
this.renderedText = settings.renderedText;
} else if (settings.html !== undefined) {
this.html = settings.html;
this.text = settings.text;
this.tagLanguage = settings.tagLanguage;
} else if (this.campaign && this.campaign.source === CampaignSource.TEMPLATE) {
this.template = await templates.getByIdTx(tx, contextHelpers.getAdminContext(), this.campaign.data.sourceTemplate, false);
this.html = this.template.html;
this.text = this.template.text;
this.tagLanguage = this.template.tag_language;
} else if (this.campaign && (this.campaign.source === CampaignSource.CUSTOM || this.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || this.campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN)) {
this.html = this.campaign.data.sourceCustom.html;
this.text = this.campaign.data.sourceCustom.text;
this.tagLanguage = this.campaign.data.sourceCustom.tag_language;
}
enforce(this.renderedHtml || (this.campaign && this.campaign.source === CampaignSource.URL) || this.tagLanguage);
if (settings.subject !== undefined) {
this.subject = settings.subject;
} else if (this.campaign && this.campaign.subject !== undefined) {
this.subject = this.campaign.subject;
} else {
enforce(false);
}
});
}
async _getMessage(mergeTags, list, subscriptionGrouped, replaceDataImgs) {
let html = '';
let text = '';
let renderTags = false;
const campaign = this.campaign;
if (this.renderedHtml !== undefined) {
html = this.renderedHtml;
text = this.renderedText;
renderTags = false;
} else if (this.html !== undefined) {
enforce(mergeTags);
html = this.html;
text = this.text;
renderTags = true;
} else if (campaign && campaign.source === CampaignSource.URL) {
const form = tools.getMessageLinks(campaign, list, subscriptionGrouped);
for (const key in mergeTags) {
form[key] = mergeTags[key];
}
const response = await request.post({
uri: campaign.sourceUrl,
form,
resolveWithFullResponse: true
});
if (response.statusCode !== 200) {
throw new Error(`Received status code ${httpResponse.statusCode} from ${campaign.sourceUrl}`);
}
html = response.body;
text = '';
renderTags = false;
}
const attachments = this.attachments.slice();
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';
attachments.push({
path: dataUri,
cid
});
return prefix + 'cid:' + cid;
});
}
if (renderTags) {
if (this.campaign) {
html = await links.updateLinks(html, this.tagLanguage, mergeTags, campaign, list, subscriptionGrouped);
}
// When no list and subscriptionGrouped is provided, formatCampaignTemplate works the same way as formatTemplate
html = tools.formatCampaignTemplate(html, this.tagLanguage, mergeTags, true, campaign, list, subscriptionGrouped);
}
const generateText = !!(text || '').trim();
if (generateText) {
text = htmlToText.fromString(html, {wordwrap: 130});
} else {
// When no list and subscriptionGrouped is provided, formatCampaignTemplate works the same way as formatTemplate
text = tools.formatCampaignTemplate(text, this.tagLanguage, mergeTags, false, campaign, list, subscriptionGrouped)
}
return {
html,
text,
attachments
};
}
_getExtraTags(campaign) {
const tags = {};
if (campaign && campaign.type === CampaignType.RSS_ENTRY) {
const rssEntry = campaign.data.rssEntry;
tags['RSS_ENTRY_TITLE'] = rssEntry.title;
tags['RSS_ENTRY_DATE'] = rssEntry.date;
tags['RSS_ENTRY_LINK'] = rssEntry.link;
tags['RSS_ENTRY_CONTENT'] = rssEntry.content;
tags['RSS_ENTRY_SUMMARY'] = rssEntry.summary;
tags['RSS_ENTRY_IMAGE_URL'] = rssEntry.imageUrl;
tags['RSS_ENTRY_CUSTOM_TAGS'] = rssEntry.customTags;
}
return tags;
}
async initByCampaignId(campaignId) {
await this._init({type: MessageType.REGULAR, campaignId});
}
/*
Accepted combinations of subData:
Option #1
- listId
- subscriptionId
- mergeTags [optional, used only when campaign / html+text is provided]
Option #2:
- to ... email / { name, address }
- encryptionKeys [optional]
- mergeTags [used only when campaign / html+text is provided]
*/
async _sendMessage(subData) {
let to, email;
let envelope = false;
let sender = false;
let headers = {};
let listHeader = false;
let encryptionKeys = [];
let subject;
let message;
let subscriptionGrouped, list; // May be undefined
const campaign = this.campaign; // May be undefined
let mergeTags = subData.mergeTags;
if (subData.listId) {
let listId;
if (subData.subscriptionId) {
listId = subData.listId;
subscriptionGrouped = await subscriptions.getById(contextHelpers.getAdminContext(), listId, subData.subscriptionId);
}
list = this.listsById.get(listId);
email = subscriptionGrouped.email;
const flds = this.listsFieldsGrouped.get(list.id);
if (!mergeTags) {
mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
}
for (const fld of flds) {
if (fld.type === 'gpg' && mergeTags[fld.key]) {
encryptionKeys.push(mergeTags[fld.key].trim());
}
}
message = await this._getMessage(mergeTags, list, subscriptionGrouped, true);
let listUnsubscribe = null;
if (!list.listunsubscribe_disabled) {
listUnsubscribe = campaign && campaign.unsubscribe_url
? tools.formatCampaignTemplate(campaign.unsubscribe_url, this.tagLanguage, mergeTags, false, campaign, list, subscriptionGrouped)
: getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscriptionGrouped.cid);
}
to = {
name: list.to_name === null ? undefined : tools.formatCampaignTemplate(list.to_name, toNameTagLangauge, mergeTags, false, campaign, list, subscriptionGrouped),
address: subscriptionGrouped.email
};
subject = this.subject;
if (this.tagLanguage) {
subject = tools.formatCampaignTemplate(this.subject, this.tagLanguage, mergeTags, false, campaign, list, subscriptionGrouped);
}
headers = {
'List-ID': {
prepared: true,
value: libmime.encodeWords(list.name) + ' <' + list.cid + '.' + getPublicUrl() + '>'
}
};
if (campaign) {
const campaignAddress = [campaign.cid, list.cid, subscriptionGrouped.cid].join('.');
if (this.useVerp) {
envelope = {
from: campaignAddress + '@' + sendConfiguration.verp_hostname,
to: subscriptionGrouped.email
};
}
if (this.useVerpSenderHeader) {
sender = campaignAddress + '@' + sendConfiguration.verp_hostname;
}
headers['x-fbl'] = campaignAddress;
headers['x-msys-api'] = JSON.stringify({
campaign_id: campaignAddress
});
headers['x-smtpapi'] = JSON.stringify({
unique_args: {
campaign_id: campaignAddress
}
});
headers['x-mailgun-variables'] = JSON.stringify({
campaign_id: campaignAddress
});
}
listHeader = {
unsubscribe: listUnsubscribe
};
} else if (subData.to) {
to = subData.to;
email = to.address;
subject = this.subject;
encryptionKeys = subData.encryptionKeys;
message = await this._getMessage(mergeTags);
}
if (await blacklist.isBlacklisted(email)) {
return;
}
const sendConfiguration = this.sendConfiguration;
const mailer = await mailers.getOrCreateMailer(sendConfiguration.id);
await mailer.throttleWait();
const getOverridable = key => {
if (campaign && sendConfiguration[key + '_overridable'] && campaign[key + '_override'] !== null) {
return campaign[key + '_override'] || '';
} else {
return sendConfiguration[key] || '';
}
};
const mail = {
from: {
name: getOverridable('from_name'),
address: getOverridable('from_email')
},
replyTo: getOverridable('reply_to'),
xMailer: sendConfiguration.x_mailer ? sendConfiguration.x_mailer : false,
to,
sender,
envelope,
headers,
list: listHeader,
subject,
html: message.html,
text: message.text,
attachments: message.attachments,
encryptionKeys
};
let response;
let responseId = null;
const info = this.isMassMail ? await mailer.sendMassMail(mail) : await mailer.sendTransactionalMail(mail);
log.verbose('MessageSender', `response: ${info.response} messageId: ${info.messageId}`);
let match;
if ((match = info.response.match(/^250 Message queued as ([0-9a-f]+)$/))) {
/*
ZoneMTA
info.response: 250 Message queued as 1691ad7f7ae00080fd
info.messageId: <e65c9386-e899-7d01-b21e-ec03c3a9d9b4@sathyasai.org>
*/
response = info.response;
responseId = match[1];
} else if ((match = info.messageId.match(/^<([^>@]*)@.*amazonses\.com>$/))) {
/*
AWS SES
info.response: 0102016ad2244c0a-955492f2-9194-4cd1-bef9-70a45906a5a7-000000
info.messageId: <0102016ad2244c0a-955492f2-9194-4cd1-bef9-70a45906a5a7-000000@eu-west-1.amazonses.com>
*/
response = info.response;
responseId = match[1];
} else if (info.response.match(/^250 OK$/) && (match = info.messageId.match(/^<([^>]*)>$/))) {
/*
Postal Mail Server
info.response: 250 OK
info.messageId: <xxxxxxxxx@xxx.xx> (postal messageId)
*/
response = info.response;
responseId = match[1];
} else {
/*
Fallback - Mailtrain v1 behavior
*/
response = info.response || info.messageId;
responseId = response.split(/\s+/).pop();
}
const result = {
response,
responseId: responseId,
list,
subscriptionGrouped,
email
};
return result;
}
async sendRegularCampaignMessage(campaignMessage) {
enforce(this.type === MessageType.REGULAR);
// We set the campaign_message to SENT before the message is actually sent. This is to avoid multiple delivery
// if by chance we run out of disk space and couldn't change status in the database after the message has been sent out
await knex('campaign_messages')
.where({id: campaignMessage.id})
.update({
status: CampaignMessageStatus.SENT,
updated: new Date()
});
let result;
try {
result = await this._sendMessage({listId: campaignMessage.list, subscriptionId: campaignMessage.subscription});
} catch (err) {
await knex('campaign_messages')
.where({id: campaignMessage.id})
.update({
status: CampaignMessageStatus.SCHEDULED,
updated: new Date()
});
throw err;
}
enforce(result.list);
enforce(result.subscriptionGrouped);
await knex('campaign_messages')
.where({id: campaignMessage.id})
.update({
response: result.response,
response_id: result.responseId,
updated: new Date()
});
await knex('campaigns').where('id', this.campaign.id).increment('delivered');
}
}
async function sendQueuedMessage(queuedMessage) {
const messageType = queuedMessage.type;
enforce(messageType === MessageType.TRIGGERED || messageType === MessageType.TEST || messageType === MessageType.SUBSCRIPTION || messageType === MessageType.API_TRANSACTIONAL);
const msgData = queuedMessage.data;
const cs = new MessageSender();
await cs._init({
type: messageType,
campaignId: msgData.campaignId,
listId: msgData.listId,
sendConfigurationId: queuedMessage.send_configuration,
attachments: msgData.attachments,
html: msgData.html,
text: msgData.text,
subject: msgData.subject,
tagLanguage: msgData.tagLanguage,
renderedHtml: msgData.renderedHtml,
renderedText: msgData.renderedText
});
const campaign = cs.campaign;
await knex('queued')
.where({id: queuedMessage.id})
.del();
let result;
try {
result = await cs._sendMessage({
subscriptionId: msgData.subscriptionId,
listId: msgData.listId,
to: msgData.to,
encryptionKeys: msgData.encryptionKeys
});
} catch (err) {
await knex.insert({
id: queuedMessage.id,
send_configuration: queuedMessage.send_configuration,
type: queuedMessage.type,
data: JSON.stringify(queuedMessage.data)
});
throw err;
}
if (messageType === MessageType.TRIGGERED) {
await knex('campaigns').where('id', campaign.id).increment('delivered');
}
if (campaign && messageType === MessageType.TEST) {
enforce(result.list);
enforce(result.subscriptionGrouped);
try {
// Insert an entry to test_messages. This allows us to remember test sends to lists that are not
// listed in the campaign - see the check in getMessage
await knex('test_messages').insert({
campaign: campaign.id,
list: result.list.id,
subscription: result.subscriptionGrouped.id
});
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
// The entry is already there, so we can ignore this error
} else {
throw err;
}
}
}
if (msgData.attachments) {
for (const attachment of msgData.attachments) {
if (attachment.id) { // This means that it is an attachment recorded in table files_campaign_attachment
try {
// We ignore any errors here because we already sent the message. Thus we have to mark it as completed to avoid sending it again.
await knex.transaction(async tx => {
await files.unlockTx(tx, 'campaign', 'attachment', attachment.id);
});
} catch (err) {
log.error('MessageSender', `Error when unlocking attachment ${attachment.id} for ${result.email} (queuedId: ${queuedMessage.id})`);
log.verbose(err.stack);
}
}
}
}
}
async function dropQueuedMessage(queuedMessage) {
await knex('queued')
.where({id: queuedMessage.id})
.del();
}
async function queueCampaignMessageTx(tx, sendConfigurationId, listId, subscriptionId, messageType, messageData) {
enforce(messageType === MessageType.TRIGGERED || messageType === MessageType.TEST);
const msgData = {...messageData};
if (msgData.attachments) {
for (const attachment of messageData.attachments) {
await files.lockTx(tx,'campaign', 'attachment', attachment.id);
}
}
msgData.listId = listId;
msgData.subscriptionId = subscriptionId;
await tx('queued').insert({
send_configuration: sendConfigurationId,
type: messageType,
data: JSON.stringify(msgData)
});
}
async function queueAPITransactionalMessage(sendConfigurationId, email, subject, html, text, tagLanguage, mergeTags, attachments) {
const msgData = {
to: {
address: email
},
html,
text,
tagLanguage,
subject,
mergeTags,
attachments
};
await tx('queued').insert({
send_configuration: sendConfigurationId,
type: MessageType.API_TRANSACTIONAL,
data: JSON.stringify(msgData)
});
}
async function queueSubscriptionMessage(sendConfigurationId, to, subject, encryptionKeys, template) {
let html, text;
const htmlRenderer = await tools.getTemplate(template.html, template.locale);
if (htmlRenderer) {
html = htmlRenderer(template.data || {});
if (html) {
html = await tools.prepareHtml(html);
}
}
const textRenderer = await tools.getTemplate(template.text, template.locale);
if (textRenderer) {
text = textRenderer(template.data || {});
} else if (html) {
text = htmlToText.fromString(html, {
wordwrap: 130
});
}
const msgData = {
renderedHtml: html,
renderedText: text,
to,
subject,
encryptionKeys
};
await knex('queued').insert({
send_configuration: sendConfigurationId,
type: MessageType.SUBSCRIPTION,
data: JSON.stringify(msgData)
});
senders.scheduleCheck();
}
async function getMessage(campaignCid, listCid, subscriptionCid) {
const cs = new MessageSender();
await cs._init({type: MessageType.REGULAR, campaignCid, listCid});
const campaign = cs.campaign;
const list = cs.listsByCid.get(listCid);
const subscriptionGrouped = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, subscriptionCid);
let listOk = false;
for (const listSpec of campaign.lists) {
if (list.id === listSpec.list) {
// This means we send to a list that is associated with the campaign
listOk = true;
break;
}
}
if (!listOk) {
const row = await knex('test_messages').where({
campaign: campaign.id,
list: list.id,
subscription: subscriptionGrouped.id
}).first();
if (row) {
listOk = true;
}
}
if (!listOk) {
throw new Error('Message not found');
}
const flds = cs.listsFieldsGrouped.get(list.id);
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, cs._getExtraTags(campaign));
return await cs._getMessage(mergeTags, list, subscriptionGrouped, false);
}
module.exports.MessageSender = MessageSender;
module.exports.MessageType = MessageType;
module.exports.sendQueuedMessage = sendQueuedMessage;
module.exports.queueCampaignMessageTx = queueCampaignMessageTx;
module.exports.queueSubscriptionMessage = queueSubscriptionMessage;
module.exports.dropQueuedMessage = dropQueuedMessage;
module.exports.getMessage = getMessage;
module.exports.queueAPITransactionalMessage = queueAPITransactionalMessage;

View file

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('./config');
const log = require('./log'); const log = require('./log');
const util = require('util'); const util = require('util');

View file

@ -1,7 +1,7 @@
'use strict'; 'use strict';
const log = require('./log'); const log = require('./log');
const config = require('config'); const config = require('./config');
const fs = require('fs-extra-promise'); const fs = require('fs-extra-promise');

View file

@ -15,6 +15,7 @@ function spawn(callback) {
log.verbose('Senders', 'Spawning master sender process'); log.verbose('Senders', 'Spawning master sender process');
knex('campaigns').where('status', CampaignStatus.SENDING).update({status: CampaignStatus.SCHEDULED}) knex('campaigns').where('status', CampaignStatus.SENDING).update({status: CampaignStatus.SCHEDULED})
.then(() => knex('campaigns').where('status', CampaignStatus.PAUSING).update({status: CampaignStatus.PAUSED}))
.then(() => { .then(() => {
senderProcess = fork(path.join(__dirname, '..', 'services', 'sender-master.js'), [], { senderProcess = fork(path.join(__dirname, '..', 'services', 'sender-master.js'), [], {
cwd: path.join(__dirname, '..'), cwd: path.join(__dirname, '..'),

View file

@ -5,11 +5,11 @@ const fields = require('../models/fields');
const settings = require('../models/settings'); const settings = require('../models/settings');
const {getTrustedUrl, getPublicUrl} = require('./urls'); const {getTrustedUrl, getPublicUrl} = require('./urls');
const { tUI, tMark } = require('./translate'); const { tUI, tMark } = require('./translate');
const util = require('util');
const contextHelpers = require('./context-helpers'); const contextHelpers = require('./context-helpers');
const {getFieldColumn} = require('../../shared/lists'); const {getFieldColumn, toNameTagLangauge} = require('../../shared/lists');
const forms = require('../models/forms'); const forms = require('../models/forms');
const mailers = require('./mailers'); const messageSender = require('./message-sender');
const tools = require('./tools');
module.exports = { module.exports = {
sendAlreadySubscribed, sendAlreadySubscribed,
@ -65,38 +65,13 @@ async function sendUnsubscriptionConfirmed(locale, list, email, subscription) {
await _sendMail(list, email, 'unsubscription_confirmed', locale, tMark('listUnsubscriptionConfirmed'), relativeUrls, subscription); await _sendMail(list, email, 'unsubscription_confirmed', locale, tMark('listUnsubscriptionConfirmed'), relativeUrls, subscription);
} }
function getDisplayName(flds, subscription) {
let firstName, lastName, name;
for (const fld of flds) {
if (fld.key === 'FIRST_NAME') {
firstName = subscription[fld.column];
}
if (fld.key === 'LAST_NAME') {
lastName = subscription[fld.column];
}
if (fld.key === 'NAME') {
name = subscription[fld.column];
}
}
if (name) {
return name;
} else if (firstName && lastName) {
return firstName + ' ' + lastName;
} else if (lastName) {
return lastName;
} else if (firstName) {
return firstName;
} else {
return '';
}
}
async function _sendMail(list, email, template, locale, subjectKey, relativeUrls, subscription) { async function _sendMail(list, email, template, locale, subjectKey, relativeUrls, subscription) {
const flds = await fields.list(contextHelpers.getAdminContext(), list.id); subscription = {
...subscription,
email
};
const flds = await fields.listGrouped(contextHelpers.getAdminContext(), list.id);
const encryptionKeys = []; const encryptionKeys = [];
for (const fld of flds) { for (const fld of flds) {
@ -137,21 +112,24 @@ async function _sendMail(list, email, template, locale, subjectKey, relativeUrls
} }
try { try {
const mergeTags = fields.getMergeTags(flds, subscription);
if (list.send_configuration) { if (list.send_configuration) {
const mailer = await mailers.getOrCreateMailer(list.send_configuration); await messageSender.queueSubscriptionMessage(
await mailer.sendTransactionalMail({ list.send_configuration,
to: { {
name: getDisplayName(flds, subscription), name: list.to_name === null ? undefined : tools.formatTemplate(list.to_name, toNameTagLangauge, mergeTags, false),
address: email address: email
}, },
subject: tUI(subjectKey, locale, { list: list.name }), tUI(subjectKey, locale, { list: list.name }),
encryptionKeys encryptionKeys,
}, { {
html, html,
text, text,
locale, locale,
data data
}); }
);
} else { } else {
log.warn('Subscription', `Not sending email for list id:${list.id} because not send configuration is set.`); log.warn('Subscription', `Not sending email for list id:${list.id} because not send configuration is set.`);
} }

View file

@ -1,88 +0,0 @@
'use strict';
const mailers = require('./mailers');
const tools = require('./tools');
const templates = require('../models/templates');
const { getMergeTagsForBases } = require('../../shared/templates');
const { getTrustedUrl, getSandboxUrl, getPublicUrl } = require('../lib/urls');
class TemplateSender {
constructor(options) {
this.defaultOptions = {
maxMails: 100,
...options
};
}
async send(params) {
const options = { ...this.defaultOptions, ...params };
this._validateMailOptions(options);
const [mailer, template] = await Promise.all([
mailers.getOrCreateMailer(
options.sendConfigurationId
),
templates.getById(
options.context,
options.templateId,
false
)
]);
const variables = {
EMAIL: options.email,
...getMergeTagsForBases(getTrustedUrl(), getSandboxUrl(), getPublicUrl()),
...options.variables
};
const html = tools.formatTemplate(
template.html,
null,
variables,
true
);
const subject = tools.formatTemplate(
options.subject || template.description || template.name,
variables
);
return mailer.sendTransactionalMail(
{
to: options.email,
subject,
attachments: options.attachments || []
},
{
html: { template: html },
data: options.data,
locale: options.locale
}
);
}
_validateMailOptions(options) {
let { context, email, locale, templateId } = options;
if (!templateId) {
throw new Error('Missing templateId');
}
if (!context) {
throw new Error('Missing context');
}
if (!email || email.length === 0) {
throw new Error('Missing email');
}
if (typeof email === 'string') {
email = email.split(',');
}
if (email.length > options.maxMails) {
throw new Error(
`Cannot send more than ${options.maxMails} emails at once`
);
}
if (!locale) {
throw new Error('Missing locale');
}
}
}
module.exports = TemplateSender;

View file

@ -1,12 +1,9 @@
'use strict'; 'use strict';
const util = require('util');
const isemail = require('isemail'); const isemail = require('isemail');
const path = require('path'); const path = require('path');
const {getPublicUrl} = require('./urls'); const {getPublicUrl} = require('./urls');
const {enforce} = require('./helpers');
const bluebird = require('bluebird');
const hasher = require('node-object-hash')(); const hasher = require('node-object-hash')();
const mjml2html = require('mjml'); const mjml2html = require('mjml');
@ -21,6 +18,8 @@ const fs = require('fs-extra');
const { JSDOM } = require('jsdom'); const { JSDOM } = require('jsdom');
const { tUI, tLog, getLangCodeFromExpressLocale } = require('./translate'); const { tUI, tLog, getLangCodeFromExpressLocale } = require('./translate');
const {TagLanguages} = require('../../shared/templates');
const templates = new Map(); const templates = new Map();
@ -42,9 +41,7 @@ async function getLocalizedFile(basePath, fileName, language) {
} }
async function getTemplate(template, locale) { async function getTemplate(template, locale) {
if (!template) { enforce(template);
return false;
}
const key = getLangCodeFromExpressLocale(locale) + ':' + ((typeof template === 'object') ? hasher.hash(template) : template); const key = getLangCodeFromExpressLocale(locale) + ':' + ((typeof template === 'object') ? hasher.hash(template) : template);
@ -127,7 +124,7 @@ async function validateEmail(address) {
function validateEmailGetMessage(result, address, language) { function validateEmailGetMessage(result, address, language) {
let t; let t;
if (language) { if (language !== undefined) {
t = (key, args) => tUI(key, language, args); t = (key, args) => tUI(key, language, args);
} else { } else {
t = (key, args) => tLog(key, args); t = (key, args) => tLog(key, args);
@ -147,23 +144,18 @@ function validateEmailGetMessage(result, address, language) {
} }
} }
function formatMessage(campaign, list, subscription, mergeTags, message, isHTML) { function formatCampaignTemplate(source, tagLanguage, mergeTags, isHTML, campaign, list, subscription) {
const links = getMessageLinks(campaign, list, subscription); const links = getMessageLinks(campaign, list, subscription);
return formatTemplate(message, links, mergeTags, isHTML); mergeTags = {...mergeTags, ...links};
return formatTemplate(source, tagLanguage, mergeTags, isHTML);
} }
function formatTemplate(template, links, mergeTags, isHTML) { function _formatTemplateSimple(source, mergeTags, isHTML) {
if (!links && !mergeTags) { return template; } if (!mergeTags) { return source; }
const getValue = fullKey => { const getValue = fullKey => {
const keys = (fullKey || '').split('.'); const keys = (fullKey || '').split('.');
if (links && links.hasOwnProperty(keys[0])) {
return links[keys[0]];
}
if (!mergeTags) { return false; }
let value = mergeTags; let value = mergeTags;
while (keys.length > 0) { while (keys.length > 0) {
let key = keys.shift(); let key = keys.shift();
@ -181,7 +173,7 @@ function formatTemplate(template, links, mergeTags, isHTML) {
}) : (containsHTML ? htmlToText.fromString(value) : value); }) : (containsHTML ? htmlToText.fromString(value) : value);
}; };
return template.replace(/\[([a-z0-9_.]+)(?:\/([^\]]+))?\]/ig, (match, identifier, fallback) => { return source.replace(/\[([a-z0-9_.]+)(?:\/([^\]]+))?\]/ig, (match, identifier, fallback) => {
let value = getValue(identifier); let value = getValue(identifier);
if (value === false) { if (value === false) {
return match; return match;
@ -191,11 +183,22 @@ function formatTemplate(template, links, mergeTags, isHTML) {
}); });
} }
async function prepareHtml(html) { function _formatTemplateHbs(source, mergeTags, isHTML) {
if (!(html || '').toString().trim()) { const renderer = hbs.handlebars.compile(source);
return false; const options = {};
return renderer(mergeTags, options);
} }
function formatTemplate(source, tagLanguage, mergeTags, isHTML) {
if (tagLanguage === TagLanguages.SIMPLE) {
return _formatTemplateSimple(source, mergeTags, isHTML)
} else if (tagLanguage === TagLanguages.HBS) {
return _formatTemplateHbs(source, mergeTags, isHTML)
}
}
async function prepareHtml(html) {
const { window } = new JSDOM(html); const { window } = new JSDOM(html);
const head = window.document.querySelector('head'); const head = window.document.querySelector('head');
@ -221,14 +224,26 @@ async function prepareHtml(html) {
} }
function getMessageLinks(campaign, list, subscription) { function getMessageLinks(campaign, list, subscription) {
return { const result = {};
LINK_UNSUBSCRIBE: getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?c=' + campaign.cid),
LINK_PREFERENCES: getPublicUrl('/subscription/' + list.cid + '/manage/' + subscription.cid), if (list && subscription) {
LINK_BROWSER: getPublicUrl('/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid), if (campaign) {
CAMPAIGN_ID: campaign.cid, result.LINK_UNSUBSCRIBE = getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?c=' + campaign.cid);
LIST_ID: list.cid, result.LINK_BROWSER = getPublicUrl('/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid);
SUBSCRIPTION_ID: subscription.cid } else {
}; result.LINK_UNSUBSCRIBE = getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscription.cid);
}
result.LINK_PREFERENCES = getPublicUrl('/subscription/' + list.cid + '/manage/' + subscription.cid);
result.LIST_ID = list.cid;
result.SUBSCRIPTION_ID = subscription.cid;
}
if (campaign) {
result.CAMPAIGN_ID = campaign.cid;
}
return result;
} }
module.exports = { module.exports = {
@ -237,7 +252,7 @@ module.exports = {
getTemplate, getTemplate,
prepareHtml, prepareHtml,
getMessageLinks, getMessageLinks,
formatMessage, formatCampaignTemplate,
formatTemplate formatTemplate
}; };

View file

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('./config');
const i18n = require("i18next"); const i18n = require("i18next");
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');

View file

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('./config');
const urllib = require('url'); const urllib = require('url');
const {anonymousRestrictedAccessToken} = require('../../shared/urls'); const {anonymousRestrictedAccessToken} = require('../../shared/urls');
const {getLangCodeFromExpressLocale} = require('./translate'); const {getLangCodeFromExpressLocale} = require('./translate');

View file

@ -41,16 +41,17 @@ async function search(context, offset, limit, search) {
async function add(context, email) { async function add(context, email) {
enforce(email, 'Email has to be set'); enforce(email, 'Email has to be set');
return await knex.transaction(async tx => {
shares.enforceGlobalPermission(context, 'manageBlacklist'); shares.enforceGlobalPermission(context, 'manageBlacklist');
const existing = await tx('blacklist').where('email', email).first(); try {
if (!existing) { await knex('blacklist').insert({email});
await tx('blacklist').insert({email});
}
await activityLog.logBlacklistActivity(BlacklistActivityType.ADD, email); await activityLog.logBlacklistActivity(BlacklistActivityType.ADD, email);
}); } catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
} else {
throw err;
}
}
} }
async function remove(context, email) { async function remove(context, email) {

View file

@ -10,23 +10,26 @@ const shares = require('./shares');
const namespaceHelpers = require('../lib/namespace-helpers'); const namespaceHelpers = require('../lib/namespace-helpers');
const files = require('./files'); const files = require('./files');
const templates = require('./templates'); const templates = require('./templates');
const { CampaignStatus, CampaignSource, CampaignType, getSendConfigurationPermissionRequiredForSend } = require('../../shared/campaigns'); const { allTagLanguages } = require('../../shared/templates');
const { CampaignMessageStatus, CampaignStatus, CampaignSource, CampaignType, getSendConfigurationPermissionRequiredForSend } = require('../../shared/campaigns');
const sendConfigurations = require('./send-configurations'); const sendConfigurations = require('./send-configurations');
const triggers = require('./triggers'); const triggers = require('./triggers');
const {SubscriptionStatus} = require('../../shared/lists'); const {SubscriptionStatus} = require('../../shared/lists');
const subscriptions = require('./subscriptions'); const subscriptions = require('./subscriptions');
const segments = require('./segments'); const segments = require('./segments');
const senders = require('../lib/senders'); const senders = require('../lib/senders');
const {LinkId} = require('./links'); const links = require('./links');
const feedcheck = require('../lib/feedcheck'); const feedcheck = require('../lib/feedcheck');
const contextHelpers = require('../lib/context-helpers'); const contextHelpers = require('../lib/context-helpers');
const {convertFileURLs} = require('../lib/campaign-content'); const {convertFileURLs} = require('../lib/campaign-content');
const messageSender = require('../lib/message-sender');
const lists = require('./lists');
const {EntityActivityType, CampaignActivityType} = require('../../shared/activity-log'); const {EntityActivityType, CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log'); const activityLog = require('../lib/activity-log');
const allowedKeysCommon = ['name', 'description', 'segment', 'namespace', const allowedKeysCommon = ['name', 'description', 'segment', 'namespace',
'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url']; 'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
const allowedKeysCreate = new Set(['type', 'source', ...allowedKeysCommon]); const allowedKeysCreate = new Set(['type', 'source', ...allowedKeysCommon]);
const allowedKeysCreateRssEntry = new Set(['type', 'source', 'parent', ...allowedKeysCommon]); const allowedKeysCreateRssEntry = new Set(['type', 'source', 'parent', ...allowedKeysCommon]);
@ -168,7 +171,7 @@ async function listTestUsersDTAjax(context, campaignId, params) {
let subsQry; let subsQry;
if (subsQrys.length === 1) { if (subsQrys.length === 1) {
const subsUnionSql = '(' + subsQrys[0].sql + ') as `test_subscriptions`' const subsUnionSql = '(' + subsQrys[0].sql + ') as `test_subscriptions`';
subsQry = knex.raw(subsUnionSql, subsQrys[0].bindings); subsQry = knex.raw(subsUnionSql, subsQrys[0].bindings);
} else { } else {
@ -182,7 +185,7 @@ async function listTestUsersDTAjax(context, campaignId, params) {
return await dtHelpers.ajaxListWithPermissionsTx( return await dtHelpers.ajaxListWithPermissionsTx(
tx, tx,
context, context,
[{ entityTypeId: 'list', requiredOperations: ['viewSubscriptions'], column: 'subs.list_id' }], [{ entityTypeId: 'list', requiredOperations: ['viewTestSubscriptions'], column: 'subs.list_id' }],
params, params,
builder => { builder => {
return builder.from(function () { return builder.from(function () {
@ -296,7 +299,7 @@ async function listOpensDTAjax(context, campaignId, params) {
return this.from('campaign_links') return this.from('campaign_links')
.where('campaign_links.campaign', campaignId) .where('campaign_links.campaign', campaignId)
.where('campaign_links.list', cpgList.list) .where('campaign_links.list', cpgList.list)
.where('campaign_links.link', LinkId.OPEN) .where('campaign_links.link', links.LinkId.OPEN)
.as('related_campaign_links'); .as('related_campaign_links');
}, },
'related_campaign_links.subscription', subsTable + '.id') 'related_campaign_links.subscription', subsTable + '.id')
@ -336,13 +339,18 @@ async function getTrackingSettingsByCidTx(tx, cid) {
return entity; return entity;
} }
async function lockByIdTx(tx, id) {
// This locks the entry for update
await tx('campaigns').where('id', id).forUpdate();
}
async function rawGetByTx(tx, key, id) { async function rawGetByTx(tx, key, id) {
const entity = await tx('campaigns').where('campaigns.' + key, id) const entity = await tx('campaigns').where('campaigns.' + key, id)
.leftJoin('campaign_lists', 'campaigns.id', 'campaign_lists.campaign') .leftJoin('campaign_lists', 'campaigns.id', 'campaign_lists.campaign')
.groupBy('campaigns.id') .groupBy('campaigns.id')
.select([ .select([
'campaigns.id', 'campaigns.cid', 'campaigns.name', 'campaigns.description', 'campaigns.namespace', 'campaigns.status', 'campaigns.type', 'campaigns.source', 'campaigns.id', 'campaigns.cid', 'campaigns.name', 'campaigns.description', 'campaigns.namespace', 'campaigns.status', 'campaigns.type', 'campaigns.source',
'campaigns.send_configuration', 'campaigns.from_name_override', 'campaigns.from_email_override', 'campaigns.reply_to_override', 'campaigns.subject_override', 'campaigns.send_configuration', 'campaigns.from_name_override', 'campaigns.from_email_override', 'campaigns.reply_to_override', 'campaigns.subject',
'campaigns.data', 'campaigns.click_tracking_disabled', 'campaigns.open_tracking_disabled', 'campaigns.unsubscribe_url', 'campaigns.scheduled', 'campaigns.data', 'campaigns.click_tracking_disabled', 'campaigns.open_tracking_disabled', 'campaigns.unsubscribe_url', 'campaigns.scheduled',
'campaigns.delivered', 'campaigns.unsubscribed', 'campaigns.bounced', 'campaigns.complained', 'campaigns.blacklisted', 'campaigns.opened', 'campaigns.clicks', 'campaigns.delivered', 'campaigns.unsubscribed', 'campaigns.bounced', 'campaigns.complained', 'campaigns.blacklisted', 'campaigns.opened', 'campaigns.clicks',
knex.raw(`GROUP_CONCAT(CONCAT_WS(\':\', campaign_lists.list, campaign_lists.segment) ORDER BY campaign_lists.id SEPARATOR \';\') as lists`) knex.raw(`GROUP_CONCAT(CONCAT_WS(\':\', campaign_lists.list, campaign_lists.segment) ORDER BY campaign_lists.id SEPARATOR \';\') as lists`)
@ -382,11 +390,13 @@ async function getByIdTx(tx, context, id, withPermissions = true, content = Cont
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats'); await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
const unsentQryGen = await getSubscribersQueryGeneratorTx(tx, id); const totalRes = await tx('campaign_messages')
if (unsentQryGen) { .where({campaign: id})
const res = await unsentQryGen(tx).count('* AS subscriptionsToSend').first(); .whereIn('status', [CampaignMessageStatus.SCHEDULED, CampaignMessageStatus.SENT,
entity.subscriptionsToSend = res.subscriptionsToSend; CampaignMessageStatus.COMPLAINED, CampaignMessageStatus.UNSUBSCRIBED, CampaignMessageStatus.BOUNCED])
} .count('* as count').first();
entity.total = totalRes.count;
} else if (content === Content.WITHOUT_SOURCE_CUSTOM) { } else if (content === Content.WITHOUT_SOURCE_CUSTOM) {
delete entity.data.sourceCustom; delete entity.data.sourceCustom;
@ -443,6 +453,10 @@ async function _validateAndPreprocess(tx, context, entity, isCreate, content) {
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', entity.send_configuration, 'viewPublic'); await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', entity.send_configuration, 'viewPublic');
} }
if ((isCreate && entity.source === CampaignSource.CUSTOM) || (content === Content.ONLY_SOURCE_CUSTOM)) {
enforce(allTagLanguages.includes(entity.data.sourceCustom.tag_language), `Invalid tag language '${entity.data.sourceCustom.tag_language}'`);
}
} }
async function _createTx(tx, context, entity, content) { async function _createTx(tx, context, entity, content) {
@ -460,6 +474,7 @@ async function _createTx(tx, context, entity, content) {
entity.data.sourceCustom = { entity.data.sourceCustom = {
type: template.type, type: template.type,
tag_language: template.tag_language,
data: template.data, data: template.data,
html: template.html, html: template.html,
text: template.text text: template.text
@ -557,7 +572,7 @@ async function updateWithConsistencyCheck(context, entity, content) {
} else if (content === Content.WITHOUT_SOURCE_CUSTOM) { } else if (content === Content.WITHOUT_SOURCE_CUSTOM) {
filteredEntity.data.sourceCustom = existing.data.sourceCustom; filteredEntity.data.sourceCustom = existing.data.sourceCustom;
await namespaceHelpers.validateMove(context, filteredEntity, existing, 'campaign', 'createCampaign', 'delete'); // XXX TB - try with entity await namespaceHelpers.validateMove(context, entity, existing, 'campaign', 'createCampaign', 'delete');
} else if (content === Content.ONLY_SOURCE_CUSTOM) { } else if (content === Content.ONLY_SOURCE_CUSTOM) {
const data = existing.data; const data = existing.data;
@ -632,21 +647,32 @@ async function remove(context, id) {
}); });
} }
async function enforceSendPermissionTx(tx, context, campaignId) { async function enforceSendPermissionTx(tx, context, campaignOrCampaignId, isToTestUsers, listId) {
let campaign; let campaign;
if (typeof campaignId === 'object') { if (typeof campaignOrCampaignId === 'object') {
campaign = campaignId; campaign = campaignOrCampaignId;
} else { } else {
campaign = await getByIdTx(tx, context, campaignId, false); campaign = await getByIdTx(tx, context, campaignOrCampaignId, false);
} }
const sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.send_configuration, false, false); const sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.send_configuration, false, false);
const requiredPermission = getSendConfigurationPermissionRequiredForSend(campaign, sendConfiguration); const requiredSendConfigurationPermission = getSendConfigurationPermissionRequiredForSend(campaign, sendConfiguration);
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', campaign.send_configuration, requiredSendConfigurationPermission);
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', campaign.send_configuration, requiredPermission); const requiredListAndCampaignPermission = isToTestUsers ? 'sendToTestUsers' : 'send';
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaign.id, 'send');
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaign.id, requiredListAndCampaignPermission);
if (listId) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, requiredListAndCampaignPermission);
} else {
for (const listIds of campaign.lists) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listIds.list, requiredListAndCampaignPermission);
}
}
} }
@ -675,7 +701,7 @@ async function getMessageByCid(messageCid, withVerpHostname = false) { // withVe
.where(subscrTblName + '.cid', subscriptionCid) .where(subscrTblName + '.cid', subscriptionCid)
.where('campaigns.cid', campaignCid) .where('campaigns.cid', campaignCid)
.select([ .select([
'campaign_messages.id', 'campaign_messages.campaign', 'campaign_messages.list', 'campaign_messages.subscription', 'campaign_messages.status' 'campaign_messages.id', 'campaign_messages.campaign', 'campaign_messages.list', 'campaign_messages.subscription', 'campaign_messages.hash_email', 'campaign_messages.status'
]) ])
.first(); .first();
@ -692,55 +718,45 @@ async function getMessageByCid(messageCid, withVerpHostname = false) { // withVe
} }
async function getMessageByResponseId(responseId) { async function getMessageByResponseId(responseId) {
return await knex.transaction(async tx => { return await knex('campaign_messages')
const message = await tx('campaign_messages')
.where('campaign_messages.response_id', responseId) .where('campaign_messages.response_id', responseId)
.select([ .select([
'campaign_messages.id', 'campaign_messages.campaign', 'campaign_messages.list', 'campaign_messages.subscription', 'campaign_messages.status' 'campaign_messages.id', 'campaign_messages.campaign', 'campaign_messages.list', 'campaign_messages.subscription', 'campaign_messages.hash_email', 'campaign_messages.status'
]) ])
.first(); .first();
return message;
});
} }
const statusFieldMapping = { const statusFieldMapping = new Map();
[SubscriptionStatus.UNSUBSCRIBED]: 'unsubscribed', statusFieldMapping.set(CampaignMessageStatus.UNSUBSCRIBED, 'unsubscribed');
[SubscriptionStatus.BOUNCED]: 'bounced', statusFieldMapping.set(CampaignMessageStatus.BOUNCED, 'bounced');
[SubscriptionStatus.COMPLAINED]: 'complained' statusFieldMapping.set(CampaignMessageStatus.COMPLAINED, 'complained');
};
async function _changeStatusByMessageTx(tx, context, message, subscriptionStatus) { async function _changeStatusByMessageTx(tx, context, message, campaignMessageStatus) {
enforce(subscriptionStatus !== SubscriptionStatus.SUBSCRIBED); enforce(statusFieldMapping.has(campaignMessageStatus));
if (message.status === SubscriptionStatus.SUBSCRIBED) { if (message.status === SubscriptionStatus.SENT) {
await shares.enforceEntityPermissionTx(tx, context, 'campaign', message.campaign, 'manageMessages'); await shares.enforceEntityPermissionTx(tx, context, 'campaign', message.campaign, 'manageMessages');
if (!subscriptionStatus in statusFieldMapping) { const statusField = statusFieldMapping.get(campaignMessageStatus);
throw new Error('Unrecognized message status');
}
const statusField = statusFieldMapping[subscriptionStatus];
await tx('campaigns').increment(statusField, 1).where('id', message.campaign); await tx('campaigns').increment(statusField, 1).where('id', message.campaign);
await tx('campaign_messages') await tx('campaign_messages')
.where('id', message.id) .where('id', message.id)
.update({ .update({
status: subscriptionStatus, status: campaignMessageStatus,
updated: knex.fn.now() updated: knex.fn.now()
}); });
} }
} }
async function changeStatusByCampaignCidAndSubscriptionIdTx(tx, context, campaignCid, listId, subscriptionId, subscriptionStatus) { async function changeStatusByCampaignCidAndSubscriptionIdTx(tx, context, campaignCid, listId, subscriptionId, campaignMessageStatus) {
const campaign = await tx('campaigns').where('cid', campaignCid);
const message = await tx('campaign_messages') const message = await tx('campaign_messages')
.innerJoin('campaigns', 'campaign_messages.campaign', 'campaigns.id') .innerJoin('campaigns', 'campaign_messages.campaign', 'campaigns.id')
.where('campaigns.cid', campaignCid) .where('campaigns.cid', campaignCid)
.where({subscription: subscriptionId, list: listId}) .where({subscription: subscriptionId, list: listId})
.select([ .select([
'campaign_messages.id', 'campaign_messages.campaign', 'campaign_messages.list', 'campaign_messages.subscription', 'campaign_messages.status' 'campaign_messages.id', 'campaign_messages.campaign', 'campaign_messages.list', 'campaign_messages.subscription', 'campaign_messages.hash_email', 'campaign_messages.status'
]) ])
.first(); .first();
@ -748,17 +764,23 @@ async function changeStatusByCampaignCidAndSubscriptionIdTx(tx, context, campaig
throw new Error('Invalid campaign.'); throw new Error('Invalid campaign.');
} }
await _changeStatusByMessageTx(tx, context, message, subscriptionStatus); await _changeStatusByMessageTx(tx, context, message, campaignMessageStatus);
} }
async function changeStatusByMessage(context, message, subscriptionStatus, updateSubscription) {
const campaignMessageStatusToSubscriptionStatusMapping = new Map();
campaignMessageStatusToSubscriptionStatusMapping.set(CampaignMessageStatus.BOUNCED, SubscriptionStatus.BOUNCED);
campaignMessageStatusToSubscriptionStatusMapping.set(CampaignMessageStatus.UNSUBSCRIBED, SubscriptionStatus.UNSUBSCRIBED);
campaignMessageStatusToSubscriptionStatusMapping.set(CampaignMessageStatus.COMPLAINED, SubscriptionStatus.COMPLAINED);
async function changeStatusByMessage(context, message, campaignMessageStatus, updateSubscription) {
await knex.transaction(async tx => { await knex.transaction(async tx => {
if (updateSubscription) { if (updateSubscription) {
await subscriptions.changeStatusTx(tx, context, message.list, message.subscription, subscriptionStatus); enforce(campaignMessageStatusToSubscriptionStatusMapping.has(campaignMessageStatus));
await subscriptions.changeStatusTx(tx, context, message.list, message.subscription, campaignMessageStatusToSubscriptionStatusMapping.get(campaignMessageStatus));
} }
await _changeStatusByMessageTx(tx, context, message, subscriptionStatus); await _changeStatusByMessageTx(tx, context, message, campaignMessageStatus);
}); });
} }
@ -773,93 +795,85 @@ async function updateMessageResponse(context, message, response, responseId) {
}); });
} }
async function getSubscribersQueryGeneratorTx(tx, campaignId) { async function prepareCampaignMessages(campaignId) {
/* const campaign = await getById(contextHelpers.getAdminContext(), campaignId, false);
This is supposed to produce queries like this:
select ... from `campaign_lists` inner join ( await knex('campaign_messages').where({campaign: campaignId, status: CampaignMessageStatus.SCHEDULED}).del();
select `email`, min(`campaign_list_id`) as `campaign_list_id`, max(`sent`) as `sent` from (
(select `subscription__2`.`email`, 8 AS campaign_list_id, related_campaign_messages.id IS NOT NULL AS sent from `subscription__2` left join
(select * from `campaign_messages` where `campaign_messages`.`campaign` = 1 and `campaign_messages`.`list` = 2)
as `related_campaign_messages` on `related_campaign_messages`.`subscription` = `subscription__2`.`id` where `subscription__2`.`status` = 1)
UNION ALL
(select `subscription__1`.`email`, 9 AS campaign_list_id, related_campaign_messages.id IS NOT NULL AS sent from `subscription__1` left join
(select * from `campaign_messages` where `campaign_messages`.`campaign` = 1 and `campaign_messages`.`list` = 1)
as `related_campaign_messages` on `related_campaign_messages`.`subscription` = `subscription__1`.`id` where `subscription__1`.`status` = 1)
) as `pending_subscriptions_all` where `sent` = false group by `email`)
as `pending_subscriptions` on `campaign_lists`.`id` = `pending_subscriptions`.`campaign_list_id` where `campaign_lists`.`campaign` = '1'
This was too much for Knex, so we partially construct these queries directly as strings; for (const cpgList of campaign.lists) {
*/ let addSegmentQuery;
await knex.transaction(async tx => {
const subsQrys = []; addSegmentQuery = cpgList.segment ? await segments.getQueryGeneratorTx(tx, cpgList.list, cpgList.segment) : () => {};
const cpgLists = await tx('campaign_lists').where('campaign', campaignId); });
for (const cpgList of cpgLists) {
const addSegmentQuery = cpgList.segment ? await segments.getQueryGeneratorTx(tx, cpgList.list, cpgList.segment) : () => {};
const subsTable = subscriptions.getSubscriptionTableName(cpgList.list); const subsTable = subscriptions.getSubscriptionTableName(cpgList.list);
const sqlQry = knex.from(subsTable) const subsQry = knex.from(subsTable)
.leftJoin(
function () {
return this.from('campaign_messages')
.where('campaign_messages.campaign', campaignId)
.where('campaign_messages.list', cpgList.list)
.as('related_campaign_messages');
},
'related_campaign_messages.subscription', subsTable + '.id')
.where(subsTable + '.status', SubscriptionStatus.SUBSCRIBED) .where(subsTable + '.status', SubscriptionStatus.SUBSCRIBED)
.where(function() { .where(function() {
addSegmentQuery(this); addSegmentQuery(this);
}) })
.select([subsTable + '.email', knex.raw('? AS campaign_list_id', [cpgList.id]), knex.raw('related_campaign_messages.id IS NOT NULL AS sent')]) .select([
'hash_email',
'id',
knex.raw('? AS campaign', [campaign.id]),
knex.raw('? AS list', [cpgList.list]),
knex.raw('? AS send_configuration', [campaign.send_configuration]),
knex.raw('? AS status', [CampaignMessageStatus.SCHEDULED])
])
.toSQL().toNative(); .toSQL().toNative();
subsQrys.push(sqlQry); await knex.raw('INSERT IGNORE INTO `campaign_messages` (`hash_email`, `subscription`, `campaign`, `list`, `send_configuration`, `status`) ' + subsQry.sql, subsQry.bindings);
}
if (subsQrys.length > 0) {
let subsQry;
const unsentWhere = ' where `sent` = false';
if (subsQrys.length === 1) {
const subsUnionSql = '(select `email`, `campaign_list_id`, `sent` from (' + subsQrys[0].sql + ') as `pending_subscriptions_all`' + unsentWhere + ') as `pending_subscriptions`'
subsQry = knex.raw(subsUnionSql, subsQrys[0].bindings);
} else {
const subsUnionSql = '(select `email`, min(`campaign_list_id`) as `campaign_list_id`, max(`sent`) as `sent` from (' +
subsQrys.map(qry => '(' + qry.sql + ')').join(' UNION ALL ') +
') as `pending_subscriptions_all`' + unsentWhere + ' group by `email`) as `pending_subscriptions`';
const subsUnionBindings = Array.prototype.concat(...subsQrys.map(qry => qry.bindings));
subsQry = knex.raw(subsUnionSql, subsUnionBindings);
}
return knx => knx.from('campaign_lists')
.where('campaign_lists.campaign', campaignId)
.innerJoin(subsQry, 'campaign_lists.id', 'pending_subscriptions.campaign_list_id');
} else {
return null;
} }
} }
async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, scheduled = null) { async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, extraData) {
await knex.transaction(async tx => { await knex.transaction(async tx => {
const entity = await tx('campaigns').where('id', campaignId).first(); // This is quite inefficient because it selects the same row 3 times. However as status is changed
if (!entity) { // rather infrequently, we keep it this way for simplicity
throw new interoperableErrors.NotFoundError(); await lockByIdTx(tx, campaignId);
}
await enforceSendPermissionTx(tx, context, entity); const entity = await getByIdTx(tx, context, campaignId, false);
await enforceSendPermissionTx(tx, context, entity, false);
if (!permittedCurrentStates.includes(entity.status)) { if (!permittedCurrentStates.includes(entity.status)) {
throw new interoperableErrors.InvalidStateError(invalidStateMessage); throw new interoperableErrors.InvalidStateError(invalidStateMessage);
} }
await tx('campaigns').where('id', campaignId).update({ if (Array.isArray(newState)) {
status: newState, const newStateIdx = permittedCurrentStates.indexOf(entity.status);
scheduled enforce(newStateIdx != -1);
newState = newState[newStateIdx];
}
const updateData = {
status: newState
};
if (!extraData) {
updateData.scheduled = null;
updateData.start_at = null;
} else {
const startAt = extraData.startAt;
// If campaign is started without "scheduled" specified, startAt === null
updateData.scheduled = startAt;
if (!startAt || startAt.valueOf() < Date.now()) {
updateData.start_at = new Date();
} else {
updateData.start_at = startAt;
}
const timezone = extraData.timezone;
if (timezone) {
updateData.data = JSON.stringify({
...entity.data,
timezone
}); });
}
}
await tx('campaigns').where('id', campaignId).update(updateData);
await activityLog.logEntityActivity('campaign', CampaignActivityType.STATUS_CHANGE, campaignId, {status: newState}); await activityLog.logEntityActivity('campaign', CampaignActivityType.STATUS_CHANGE, campaignId, {status: newState});
}); });
@ -868,22 +882,23 @@ async function _changeStatus(context, campaignId, permittedCurrentStates, newSta
} }
async function start(context, campaignId, startAt) { async function start(context, campaignId, extraData) {
await _changeStatus(context, campaignId, [CampaignStatus.IDLE, CampaignStatus.SCHEDULED, CampaignStatus.PAUSED, CampaignStatus.FINISHED], CampaignStatus.SCHEDULED, 'Cannot start campaign until it is in IDLE or PAUSED state', startAt); await _changeStatus(context, campaignId, [CampaignStatus.IDLE, CampaignStatus.SCHEDULED, CampaignStatus.PAUSED, CampaignStatus.FINISHED], CampaignStatus.SCHEDULED, 'Cannot start campaign until it is in IDLE, PAUSED, or FINISHED state', extraData);
} }
async function stop(context, campaignId) { async function stop(context, campaignId) {
await _changeStatus(context, campaignId, [CampaignStatus.SCHEDULED], CampaignStatus.PAUSED, 'Cannot stop campaign until it is in SCHEDULED state'); await _changeStatus(context, campaignId, [CampaignStatus.SCHEDULED, CampaignStatus.SENDING], [CampaignStatus.PAUSED, CampaignStatus.PAUSING], 'Cannot stop campaign until it is in SCHEDULED or SENDING state');
} }
async function reset(context, campaignId) { async function reset(context, campaignId) {
await knex.transaction(async tx => { await knex.transaction(async tx => {
// This is quite inefficient because it selects the same row 3 times. However as RESET is
// going to be called rather infrequently, we keep it this way for simplicity
await lockByIdTx(tx, campaignId);
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'send'); await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'send');
const entity = await tx('campaigns').where('id', campaignId).first(); const entity = await tx('campaigns').where('id', campaignId).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
if (entity.status !== CampaignStatus.FINISHED && entity.status !== CampaignStatus.PAUSED) { if (entity.status !== CampaignStatus.FINISHED && entity.status !== CampaignStatus.PAUSED) {
throw new interoperableErrors.InvalidStateError('Cannot reset campaign until it is FINISHED or PAUSED state'); throw new interoperableErrors.InvalidStateError('Cannot reset campaign until it is FINISHED or PAUSED state');
@ -919,8 +934,8 @@ async function getStatisticsOpened(context, id) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats'); await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
const devices = await tx('campaign_links').where('campaign', id).where('link', LinkId.OPEN).groupBy('device_type').select('device_type AS key').count('* as count'); const devices = await tx('campaign_links').where('campaign', id).where('link', links.LinkId.OPEN).groupBy('device_type').select('device_type AS key').count('* as count');
const countries = await tx('campaign_links').where('campaign', id).where('link', LinkId.OPEN).groupBy('country').select('country AS key').count('* as count'); const countries = await tx('campaign_links').where('campaign', id).where('link', links.LinkId.OPEN).groupBy('country').select('country AS key').count('* as count');
return { return {
devices, devices,
@ -944,6 +959,105 @@ async function fetchRssCampaign(context, cid) {
}); });
} }
async function testSend(context, data) {
// Though it's a bit counter-intuitive, this handles also test sends of a template (i.e. without any campaign id)
await knex.transaction(async tx => {
const processSubscriber = async (sendConfigurationId, listId, subscriptionId, messageData) => {
await messageSender.queueCampaignMessageTx(tx, sendConfigurationId, listId, subscriptionId, messageSender.MessageType.TEST, messageData);
await activityLog.logEntityActivity('campaign', CampaignActivityType.TEST_SEND, campaignId, {list: listId, subscription: subscriptionId});
};
const campaignId = data.campaignId;
if (campaignId) { // This means we are sending a campaign
/*
Data coming from the client:
- html, text
- subjectPrepend, subjectAppend
- listCid, subscriptionCid
- listId, segmentId
*/
const campaign = await getByIdTx(tx, context, campaignId, false);
const sendConfigurationId = campaign.send_configuration;
const messageData = {
campaignId: campaignId,
subject: data.subjectPrepend + campaign.subject + data.subjectAppend,
html: data.html, // The html, text and tagLanguage may be undefined
text: data.text,
tagLanguage: data.tagLanguage,
attachments: []
};
const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', campaignId);
for (const attachment of attachments) {
messageData.attachments.push({
filename: attachment.originalname,
path: files.getFilePath('campaign', 'attachment', campaign.id, attachment.filename),
id: attachment.id
});
}
let listId = data.listId;
if (!listId && data.listCid) {
const list = await lists.getByCidTx(tx, context, data.listCid);
listId = list.id;
}
const segmentId = data.segmentId;
if (listId) {
await enforceSendPermissionTx(tx, context, campaign, true, listId);
if (data.subscriptionCid) {
const subscriber = await subscriptions.getByCidTx(tx, context, listId, data.subscriptionCid, true, true);
await processSubscriber(sendConfigurationId, listId, subscriber.id, messageData);
} else {
const subscribers = await subscriptions.listTestUsersTx(tx, context, listId, segmentId);
for (const subscriber of subscribers) {
await processSubscriber(sendConfigurationId, listId, subscriber.id, messageData);
}
}
} else {
for (const lstSeg of campaign.lists) {
await enforceSendPermissionTx(tx, context, campaign, true, lstSeg.list);
const subscribers = await subscriptions.listTestUsersTx(tx, context, lstSeg.list, segmentId);
for (const subscriber of subscribers) {
await processSubscriber(sendConfigurationId, lstSeg.list, subscriber.id, messageData);
}
}
}
} else { // This means we are sending a template
/*
Data coming from the client:
- html, text
- listCid, subscriptionCid, sendConfigurationId
*/
const messageData = {
subject: 'Test',
html: data.html,
text: data.text,
tagLanguage: data.tagLanguage
};
const list = await lists.getByCidTx(tx, context, data.listCid);
const subscriber = await subscriptions.getByCidTx(tx, context, list.id, data.subscriptionCid, true, true);
await processSubscriber(data.sendConfigurationId, list.id, subscriber.id, messageData);
}
});
senders.scheduleCheck();
}
module.exports.Content = Content; module.exports.Content = Content;
module.exports.hash = hash; module.exports.hash = hash;
@ -974,7 +1088,7 @@ module.exports.changeStatusByCampaignCidAndSubscriptionIdTx = changeStatusByCamp
module.exports.changeStatusByMessage = changeStatusByMessage; module.exports.changeStatusByMessage = changeStatusByMessage;
module.exports.updateMessageResponse = updateMessageResponse; module.exports.updateMessageResponse = updateMessageResponse;
module.exports.getSubscribersQueryGeneratorTx = getSubscribersQueryGeneratorTx; module.exports.prepareCampaignMessages = prepareCampaignMessages;
module.exports.start = start; module.exports.start = start;
module.exports.stop = stop; module.exports.stop = stop;
@ -987,3 +1101,5 @@ module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
module.exports.getStatisticsOpened = getStatisticsOpened; module.exports.getStatisticsOpened = getStatisticsOpened;
module.exports.fetchRssCampaign = fetchRssCampaign; module.exports.fetchRssCampaign = fetchRssCampaign;
module.exports.testSend = testSend;

View file

@ -21,7 +21,7 @@ async function addConfirmation(listId, action, ip, data) {
*/ */
async function takeConfirmation(cid) { async function takeConfirmation(cid) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
const entry = await tx('confirmations').select(['cid', 'list', 'action', 'ip', 'data']).where('cid', cid).first(); const entry = await tx('confirmations').select(['cid', 'list', 'action', 'ip', 'data']).where('cid', cid).forUpdate().first();
if (!entry) { if (!entry) {
return false; return false;

View file

@ -2,7 +2,6 @@
const knex = require('../lib/knex'); const knex = require('../lib/knex');
const hasher = require('node-object-hash')(); const hasher = require('node-object-hash')();
const slugify = require('slugify');
const { enforce, filterObject } = require('../lib/helpers'); const { enforce, filterObject } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers'); const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../../shared/interoperable-errors'); const interoperableErrors = require('../../shared/interoperable-errors');
@ -20,8 +19,8 @@ const {ListActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log'); const activityLog = require('../lib/activity-log');
const allowedKeysCreate = new Set(['name', 'key', 'default_value', 'type', 'group', 'settings']); const allowedKeysCreate = new Set(['name', 'help', 'key', 'default_value', 'type', 'group', 'settings']);
const allowedKeysUpdate = new Set(['name', 'key', 'default_value', 'group', 'settings']); const allowedKeysUpdate = new Set(['name', 'help', 'key', 'default_value', 'group', 'settings']);
const hashKeys = allowedKeysCreate; const hashKeys = allowedKeysCreate;
const fieldTypes = {}; const fieldTypes = {};
@ -304,7 +303,7 @@ async function getById(context, listId, id) {
} }
async function listTx(tx, listId) { async function listTx(tx, listId) {
return await tx('custom_fields').where({list: listId}).select(['id', 'name', 'type', 'key', 'column', 'settings', 'group', 'default_value', 'order_list', 'order_subscribe', 'order_manage']).orderBy(knex.raw('-order_list'), 'desc').orderBy('id', 'asc'); return await tx('custom_fields').where({list: listId}).select(['id', 'name', 'type', 'help', 'key', 'column', 'settings', 'group', 'default_value', 'order_list', 'order_subscribe', 'order_manage']).orderBy(knex.raw('-order_list'), 'desc').orderBy('id', 'asc');
} }
async function list(context, listId) { async function list(context, listId) {
@ -543,7 +542,7 @@ async function createTx(tx, context, listId, entity) {
let columnName; let columnName;
if (!fieldType.grouped) { if (!fieldType.grouped) {
columnName = ('custom_' + slugify(entity.name, '_') + '_' + shortid.generate()).toLowerCase().replace(/[^a-z0-9_]/g, ''); columnName = ('custom_' + '_' + shortid.generate()).toLowerCase().replace(/[^a-z0-9_]/g, '_');
} }
const filteredEntity = filterObject(entity, allowedKeysCreate); const filteredEntity = filterObject(entity, allowedKeysCreate);
@ -661,10 +660,11 @@ function forHbsWithFieldsGrouped(fieldsGrouped, subscription) { // assumes group
const entry = { const entry = {
name: fld.name, name: fld.name,
key: fld.key, key: fld.key,
help: fld.help,
field: fld, field: fld,
[type.getHbsType(fld)]: true, [type.getHbsType(fld)]: true,
order_subscribe: fld.order_subscribe, order_subscribe: fld.order_subscribe,
order_manage: fld.order_manage, order_manage: fld.order_manage
}; };
if (!type.grouped && !type.enumerated) { if (!type.grouped && !type.enumerated) {
@ -688,6 +688,7 @@ function forHbsWithFieldsGrouped(fieldsGrouped, subscription) { // assumes group
options.push({ options.push({
key: opt.key, key: opt.key,
name: opt.name, name: opt.name,
help: opt.help,
value: isEnabled value: isEnabled
}); });
} }
@ -702,6 +703,7 @@ function forHbsWithFieldsGrouped(fieldsGrouped, subscription) { // assumes group
options.push({ options.push({
key: opt.key, key: opt.key,
name: opt.label, name: opt.label,
help: opt.help,
value: value === opt.key value: value === opt.key
}); });
} }

View file

@ -45,7 +45,7 @@ async function listDTAjax(context, type, subType, entityId, params) {
await shares.enforceEntityPermission(context, type, entityId, getFilesPermission(type, subType, 'view')); await shares.enforceEntityPermission(context, type, entityId, getFilesPermission(type, subType, 'view'));
return await dtHelpers.ajaxList( return await dtHelpers.ajaxList(
params, params,
builder => builder.from(getFilesTable(type, subType)).where({entity: entityId}), builder => builder.from(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false}),
['id', 'originalname', 'filename', 'size', 'created'] ['id', 'originalname', 'filename', 'size', 'created']
); );
} }
@ -53,7 +53,7 @@ async function listDTAjax(context, type, subType, entityId, params) {
async function listTx(tx, context, type, subType, entityId) { async function listTx(tx, context, type, subType, entityId) {
enforceTypePermitted(type, subType); enforceTypePermitted(type, subType);
await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view')); await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view'));
return await tx(getFilesTable(type, subType)).where({entity: entityId}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc'); return await tx(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc');
} }
async function list(context, type, subType, entityId) { async function list(context, type, subType, entityId) {
@ -65,7 +65,7 @@ async function list(context, type, subType, entityId) {
async function getFileById(context, type, subType, id) { async function getFileById(context, type, subType, id) {
enforceTypePermitted(type, subType); enforceTypePermitted(type, subType);
const file = await knex.transaction(async tx => { const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type, subType)).where('id', id).first(); const file = await tx(getFilesTable(type, subType)).where({id: id, delete_pending: false}).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'view')); await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'view'));
return file; return file;
}); });
@ -85,7 +85,7 @@ async function _getFileBy(context, type, subType, entityId, key, value) {
enforceTypePermitted(type, subType); enforceTypePermitted(type, subType);
const file = await knex.transaction(async tx => { const file = await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view')); await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view'));
const file = await tx(getFilesTable(type, subType)).where({entity: entityId, [key]: value}).first(); const file = await tx(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false, [key]: value}).first();
return file; return file;
}); });
@ -155,7 +155,7 @@ async function createFiles(context, type, subType, entityId, files, replacementB
await knex.transaction(async tx => { await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'manage')); await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'manage'));
const existingNamesRows = await tx(getFilesTable(type, subType)).where('entity', entityId).select(['id', 'filename', 'originalname']); const existingNamesRows = await tx(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false}).select(['id', 'filename', 'originalname']);
const existingNameSet = new Set(); const existingNameSet = new Set();
for (const row of existingNamesRows) { for (const row of existingNamesRows) {
@ -275,18 +275,49 @@ async function createFiles(context, type, subType, entityId, files, replacementB
} }
} }
async function removeFile(context, type, subType, id) { async function lockTx(tx, type, subType, id) {
enforceTypePermitted(type, subType);
const filesTableName = getFilesTable(type, subType);
await tx(filesTableName).where('id', id).increment('lock_count');
}
async function unlockTx(tx, type, subType, id) {
enforceTypePermitted(type, subType); enforceTypePermitted(type, subType);
const file = await knex.transaction(async tx => { const filesTableName = getFilesTable(type, subType);
const file = await tx(getFilesTable(type, subType)).where('id', id).select('entity', 'filename').first(); const file = await tx(filesTableName).where('id', id).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'manage'));
await tx(getFilesTable(type, subType)).where('id', id).del(); enforce(file, `File ${id} not found`);
return {filename: file.filename, entity: file.entity}; enforce(file.lock_count > 0, `Corrupted lock count at file ${id}`);
});
if (file.lock_count === 1 && file.delete_pending) {
await tx(filesTableName).where('id', id).del();
const filePath = getFilePath(type, subType, file.entity, file.filename); const filePath = getFilePath(type, subType, file.entity, file.filename);
await fs.removeAsync(filePath); await fs.removeAsync(filePath);
} else {
await tx(filesTableName).where('id', id).update({lock_count: file.lock_count - 1});
}
}
async function removeFile(context, type, subType, id) {
enforceTypePermitted(type, subType);
await knex.transaction(async tx => {
const filesTableName = getFilesTable(type, subType);
const file = await tx(filesTableName).where('id', id).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'manage'));
if (!file.lock_count) {
await tx(filesTableName).where('id', file.id).del();
const filePath = getFilePath(type, subType, file.entity, file.filename);
await fs.removeAsync(filePath);
} else {
await tx(filesTableName).where('id', file.id).update({delete_pending: true});
}
});
} }
async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toType, toSubType, toEntityId) { async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toType, toSubType, toEntityId) {
@ -296,7 +327,7 @@ async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toTyp
enforceTypePermitted(toType, toSubType); enforceTypePermitted(toType, toSubType);
await shares.enforceEntityPermissionTx(tx, context, toType, toEntityId, getFilesPermission(toType, toSubType, 'manage')); await shares.enforceEntityPermissionTx(tx, context, toType, toEntityId, getFilesPermission(toType, toSubType, 'manage'));
const rows = await tx(getFilesTable(fromType, fromSubType)).where({entity: fromEntityId}); const rows = await tx(getFilesTable(fromType, fromSubType)).where({entity: fromEntityId, delete_pending: false});
for (const row of rows) { for (const row of rows) {
const fromFilePath = getFilePath(fromType, fromSubType, fromEntityId, row.filename); const fromFilePath = getFilePath(fromType, fromSubType, fromEntityId, row.filename);
const toFilePath = getFilePath(toType, toSubType, toEntityId, row.filename); const toFilePath = getFilePath(toType, toSubType, toEntityId, row.filename);
@ -339,4 +370,6 @@ module.exports.getFileUrl = getFileUrl;
module.exports.getFilePath = getFilePath; module.exports.getFilePath = getFilePath;
module.exports.copyAllTx = copyAllTx; module.exports.copyAllTx = copyAllTx;
module.exports.removeAllTx = removeAllTx; module.exports.removeAllTx = removeAllTx;
module.exports.lockTx = lockTx;
module.exports.unlockTx = unlockTx;
module.exports.ReplacementBehavior = ReplacementBehavior; module.exports.ReplacementBehavior = ReplacementBehavior;

View file

@ -70,7 +70,7 @@ async function listDTAjax(context, params) {
} }
async function _getById(tx, id) { async function _getByIdTx(tx, id) {
const entity = await tx('custom_forms').where('id', id).first(); const entity = await tx('custom_forms').where('id', id).first();
if (!entity) { if (!entity) {
@ -86,17 +86,20 @@ async function _getById(tx, id) {
return entity; return entity;
} }
async function getByIdTx(tx, context, id, withPermissions = true) {
async function getById(context, id, withPermissions = true) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view'); await shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view');
const entity = await _getById(tx, id); const entity = await _getByIdTx(tx, id);
if (withPermissions) { if (withPermissions) {
entity.permissions = await shares.getPermissionsTx(tx, context, 'customForm', id); entity.permissions = await shares.getPermissionsTx(tx, context, 'customForm', id);
} }
return entity; return entity;
}
async function getById(context, id, withPermissions = true) {
return await knex.transaction(async tx => {
return await getByIdTx(tx, context, id, withPermissions);
}); });
} }
@ -122,6 +125,17 @@ async function create(context, entity) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCustomForm'); await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCustomForm');
if (entity.fromExistingEntity) {
const existing = await getByIdTx(tx, context, entity.existingEntity, false);
entity.layout = existing.layout;
entity.form_input_style = existing.form_input_style;
for (const key of allowedFormKeys) {
entity[key] = existing[key];
}
}
await namespaceHelpers.validateEntity(tx, entity); await namespaceHelpers.validateEntity(tx, entity);
const form = filterObject(entity, allowedFormKeys); const form = filterObject(entity, allowedFormKeys);
@ -147,7 +161,7 @@ async function updateWithConsistencyCheck(context, entity) {
await knex.transaction(async tx => { await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'customForm', entity.id, 'edit'); await shares.enforceEntityPermissionTx(tx, context, 'customForm', entity.id, 'edit');
const existing = await _getById(tx, entity.id); const existing = await _getByIdTx(tx, entity.id);
const existingHash = hash(existing); const existingHash = hash(existing);
if (existingHash !== entity.originalHash) { if (existingHash !== entity.originalHash) {
@ -219,7 +233,7 @@ async function getDefaultCustomFormValues() {
// TODO - this could run in the browser too - move to shared // TODO - this could run in the browser too - move to shared
function checkForMjmlErrors(form) { function checkForMjmlErrors(form) {
let testLayout = '<mjml><mj-body><mj-container>{{{body}}}</mj-container></mj-body></mjml>'; let testLayout = '<mjml><mj-body>{{{body}}}</mj-body></mjml>';
let hasMjmlError = (template, layout = testLayout) => { let hasMjmlError = (template, layout = testLayout) => {
let source = layout.replace(/\{\{\{body\}\}\}/g, template); let source = layout.replace(/\{\{\{body\}\}\}/g, template);
@ -283,6 +297,7 @@ function checkForMjmlErrors(form) {
module.exports.listDTAjax = listDTAjax; module.exports.listDTAjax = listDTAjax;
module.exports.hash = hash; module.exports.hash = hash;
module.exports.getById = getById; module.exports.getById = getById;
module.exports.getByIdTx = getByIdTx;
module.exports.create = create; module.exports.create = create;
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck; module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
module.exports.remove = remove; module.exports.remove = remove;

View file

@ -11,6 +11,7 @@ const he = require('he');
const { getPublicUrl } = require('../lib/urls'); const { getPublicUrl } = require('../lib/urls');
const tools = require('../lib/tools'); const tools = require('../lib/tools');
const shortid = require('shortid'); const shortid = require('shortid');
const {enforce} = require('../lib/helpers');
const LinkId = { const LinkId = {
OPEN: -1, OPEN: -1,
@ -103,8 +104,7 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
} }
async function addOrGet(campaignId, url) { async function addOrGet(campaignId, url) {
return await knex.transaction(async tx => { const link = await knex('links').select(['id', 'cid']).where({
const link = await tx('links').select(['id', 'cid']).where({
campaign: campaignId, campaign: campaignId,
url url
}).first(); }).first();
@ -112,7 +112,8 @@ async function addOrGet(campaignId, url) {
if (!link) { if (!link) {
let cid = shortid.generate(); let cid = shortid.generate();
const ids = await tx('links').insert({ try {
const ids = await knex('links').insert({
campaign: campaignId, campaign: campaignId,
cid, cid,
url url
@ -122,16 +123,27 @@ async function addOrGet(campaignId, url) {
id: ids[0], id: ids[0],
cid cid
}; };
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
const link = await knex('links').select(['id', 'cid']).where({
campaign: campaignId,
url
}).first();
enforce(link);
return link;
}
}
} else { } else {
return link; return link;
} }
});
} }
async function updateLinks(campaign, list, subscription, mergeTags, message) { async function updateLinks(source, tagLanguage, mergeTags, campaign, list, subscription) {
if ((campaign.open_tracking_disabled && campaign.click_tracking_disabled) || !message || !message.trim()) { if ((campaign.open_tracking_disabled && campaign.click_tracking_disabled) || !source || !source.trim()) {
// tracking is disabled, do not modify the message // tracking is disabled, do not modify the message
return message; return source;
} }
// insert tracking image // insert tracking image
@ -139,12 +151,12 @@ async function updateLinks(campaign, list, subscription, mergeTags, message) {
let inserted = false; let inserted = false;
const imgUrl = getPublicUrl(`/links/${campaign.cid}/${list.cid}/${subscription.cid}`); const imgUrl = getPublicUrl(`/links/${campaign.cid}/${list.cid}/${subscription.cid}`);
const img = '<img src="' + imgUrl + '" width="1" height="1" alt="mt">'; const img = '<img src="' + imgUrl + '" width="1" height="1" alt="mt">';
message = message.replace(/<\/body\b/i, match => { source = source.replace(/<\/body\b/i, match => {
inserted = true; inserted = true;
return img + match; return img + match;
}); });
if (!inserted) { if (!inserted) {
message = message + img; source = source + img;
} }
} }
@ -153,7 +165,7 @@ async function updateLinks(campaign, list, subscription, mergeTags, message) {
const urlsToBeReplaced = new Set(); const urlsToBeReplaced = new Set();
message.replace(re, (match, prefix, encodedUrl) => { source.replace(re, (match, prefix, encodedUrl) => {
const url = he.decode(encodedUrl, {isAttributeValue: true}); const url = he.decode(encodedUrl, {isAttributeValue: true});
urlsToBeReplaced.add(url); urlsToBeReplaced.add(url);
}); });
@ -161,19 +173,19 @@ async function updateLinks(campaign, list, subscription, mergeTags, message) {
const urls = new Map(); // url -> {id, cid} (as returned by add) const urls = new Map(); // url -> {id, cid} (as returned by add)
for (const url of urlsToBeReplaced) { for (const url of urlsToBeReplaced) {
// url might include variables, need to rewrite those just as we do with message content // url might include variables, need to rewrite those just as we do with message content
const expanedUrl = tools.formatMessage(campaign, list, subscription, mergeTags, url); const expanedUrl = tools.formatCampaignTemplate(url, tagLanguage, mergeTags, false, campaign, list, subscription);
const link = await addOrGet(campaign.id, expanedUrl); const link = await addOrGet(campaign.id, expanedUrl);
urls.set(url, link); urls.set(url, link);
} }
message = message.replace(re, (match, prefix, encodedUrl) => { source = source.replace(re, (match, prefix, encodedUrl) => {
const url = he.decode(encodedUrl, {isAttributeValue: true}); const url = he.decode(encodedUrl, {isAttributeValue: true});
const link = urls.get(url); const link = urls.get(url);
return prefix + (link ? getPublicUrl(`/links/${campaign.cid}/${list.cid}/${subscription.cid}/${link.cid}`) : url); return prefix + (link ? getPublicUrl(`/links/${campaign.cid}/${list.cid}/${subscription.cid}/${link.cid}`) : url);
}); });
} }
return message; return source;
} }
module.exports.LinkId = LinkId; module.exports.LinkId = LinkId;

View file

@ -9,8 +9,9 @@ const namespaceHelpers = require('../lib/namespace-helpers');
const shares = require('./shares'); const shares = require('./shares');
const files = require('./files'); const files = require('./files');
const dependencyHelpers = require('../lib/dependency-helpers'); const dependencyHelpers = require('../lib/dependency-helpers');
const { allTagLanguages } = require('../../shared/templates');
const allowedKeys = new Set(['name', 'description', 'type', 'data', 'namespace']); const allowedKeys = new Set(['name', 'description', 'type', 'tag_language', 'data', 'namespace']);
function hash(entity) { function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys)); return hasher.hash(filterObject(entity, allowedKeys));
@ -32,11 +33,25 @@ async function listDTAjax(context, params) {
[{ entityTypeId: 'mosaicoTemplate', requiredOperations: ['view'] }], [{ entityTypeId: 'mosaicoTemplate', requiredOperations: ['view'] }],
params, params,
builder => builder.from('mosaico_templates').innerJoin('namespaces', 'namespaces.id', 'mosaico_templates.namespace'), 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' ] [ 'mosaico_templates.id', 'mosaico_templates.name', 'mosaico_templates.description', 'mosaico_templates.type', 'mosaico_templates.tag_language', 'mosaico_templates.created', 'namespaces.name' ]
);
}
async function listByTagLanguageDTAjax(context, tagLanguage, params) {
return await dtHelpers.ajaxListWithPermissions(
context,
[{ entityTypeId: 'mosaicoTemplate', requiredOperations: ['view'] }],
params,
builder => builder.from('mosaico_templates')
.innerJoin('namespaces', 'namespaces.id', 'mosaico_templates.namespace')
.where('mosaico_templates.tag_language', tagLanguage),
[ 'mosaico_templates.id', 'mosaico_templates.name', 'mosaico_templates.description', 'mosaico_templates.type', 'mosaico_templates.tag_language', 'mosaico_templates.created', 'namespaces.name' ]
); );
} }
async function _validateAndPreprocess(tx, entity) { async function _validateAndPreprocess(tx, entity) {
enforce(allTagLanguages.includes(entity.tag_language), `Invalid tag language '${entity.tag_language}'`);
entity.data = JSON.stringify(entity.data); entity.data = JSON.stringify(entity.data);
await namespaceHelpers.validateEntity(tx, entity); await namespaceHelpers.validateEntity(tx, entity);
} }
@ -58,7 +73,6 @@ async function create(context, entity) {
async function updateWithConsistencyCheck(context, entity) { async function updateWithConsistencyCheck(context, entity) {
await knex.transaction(async tx => { await knex.transaction(async tx => {
await shares.enforceGlobalPermission(context, 'createJavascriptWithROAccess');
await shares.enforceEntityPermissionTx(tx, context, 'mosaicoTemplate', entity.id, 'edit'); await shares.enforceEntityPermissionTx(tx, context, 'mosaicoTemplate', entity.id, 'edit');
const existing = await tx('mosaico_templates').where('id', entity.id).first(); const existing = await tx('mosaico_templates').where('id', entity.id).first();
@ -119,6 +133,7 @@ async function remove(context, id) {
module.exports.hash = hash; module.exports.hash = hash;
module.exports.getById = getById; module.exports.getById = getById;
module.exports.listDTAjax = listDTAjax; module.exports.listDTAjax = listDTAjax;
module.exports.listByTagLanguageDTAjax = listByTagLanguageDTAjax;
module.exports.create = create; module.exports.create = create;
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck; module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
module.exports.remove = remove; module.exports.remove = remove;

View file

@ -226,7 +226,16 @@ async function remove(context, id) {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', id, 'delete'); await shares.enforceEntityPermissionTx(tx, context, 'namespace', id, 'delete');
const entityTypesWithNamespace = Object.keys(entitySettings.getEntityTypes()); const entityTypesWithNamespace = Object.keys(entitySettings.getEntityTypes());
await dependencyHelpers.ensureNoDependencies(tx, context, id, entityTypesWithNamespace.map(entityTypeId => ({ entityTypeId: entityTypeId, column: 'namespace' })));
const depSpecs = entityTypesWithNamespace.map(entityTypeId => {
if (entityTypeId === 'user') {
return { entityTypeId: entityTypeId, column: 'namespace', viewPermission: {entityTypeId: 'namespace', entityId: id, requiredOperations: 'manageUsers'} };
} else {
return { entityTypeId: entityTypeId, column: 'namespace' };
}
});
await dependencyHelpers.ensureNoDependencies(tx, context, id, depSpecs);
await tx('namespaces').where('id', id).del(); await tx('namespaces').where('id', id).del();
}); });

Some files were not shown because too many files have changed in this diff Show more