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:
- http://localhost:3000 -> https://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:

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
import {
Button,
ButtonRow,
Dropdown,
filterData,
Form,
FormSendMethod,
@ -16,11 +17,11 @@ import {
} from '../lib/form';
import {withErrorHandling} from '../lib/error-handling';
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 styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
import {TestSendModalDialog} from "./TestSendModalDialog";
import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
import {withComponentMixins} from "../lib/decorator-helpers";
import {ContentModalDialog} from "../lib/modals";
@ -39,12 +40,18 @@ export default class CustomContent extends Component {
const t = props.t;
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN);
this.tagLanguages = getTagLanguages(props.t);
this.customTemplateTypeOptions = [];
for (const key of mailtrainConfig.editors) {
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 = {
showMergeTagReference: false,
elementInFullscreen: false,
@ -56,6 +63,9 @@ export default class CustomContent extends Component {
this.initForm({
getPreSubmitUpdater: ::this.getPreSubmitFormValuesUpdater,
onChangeBeforeValidation: {
data_sourceCustom_tag_language: ::this.onTagLanguageChanged
}
});
this.sendModalGetDataHandler = ::this.sendModalGetData;
@ -71,9 +81,16 @@ export default class CustomContent extends Component {
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) {
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_html = data.data.sourceCustom.html;
data.data_sourceCustom_text = data.data.sourceCustom.text;
@ -86,6 +103,7 @@ export default class CustomContent extends Component {
data.data.sourceCustom = {
type: data.data_sourceCustom_type,
tag_language: data.data_sourceCustom_tag_language,
data: data.data_sourceCustom_data,
html: data.data_sourceCustom_html,
text: data.data_sourceCustom_text
@ -112,6 +130,12 @@ export default class CustomContent extends Component {
localValidateFormValues(state) {
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']);
if (customTemplateTypeKey) {
@ -205,7 +229,8 @@ export default class CustomContent extends Component {
return {
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');
// FIXME - data_sourceCustom_type is initialized only after first render
return (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
<TestSendModalDialog
mode={TestSendModalDialogMode.CAMPAIGN_CONTENT}
visible={this.state.showTestSendModal}
onHide={() => this.setState({showTestSendModal: false})}
getDataAsync={this.sendModalGetDataHandler}
entity={this.props.entity}
campaign={this.props.entity}
/>
<ContentModalDialog
title={this.state.exportModalTitle}
@ -253,6 +277,8 @@ export default class CustomContent extends Component {
{customTemplateTypeKey && this.templateTypes[customTemplateTypeKey].typeName}
</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 && 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('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 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>
</Form>
</div>

View file

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

View file

@ -73,7 +73,7 @@ export default class Statistics extends Component {
render() {
const t = this.props.t;
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 val = entity[key]

View file

@ -13,10 +13,11 @@ import axios from "../lib/axios";
import {getPublicUrl, getUrl} from "../lib/urls";
import interoperableErrors from '../../../shared/interoperable-errors';
import {CampaignStatus, CampaignType} from "../../../shared/campaigns";
import moment from 'moment';
import moment from 'moment-timezone';
import campaignsStyles from "./styles.scss";
import {withComponentMixins} from "../lib/decorator-helpers";
import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
import styles from "../lib/styles.scss";
@withComponentMixins([
withTranslation,
@ -25,7 +26,7 @@ import {withComponentMixins} from "../lib/decorator-helpers";
withPageHelpers,
requiresAuthenticatedUser
])
class TestUser extends Component {
class PreviewForTestUserModalDialog extends Component {
constructor(props) {
super(props);
this.initForm({
@ -34,7 +35,9 @@ class TestUser extends Component {
}
static propTypes = {
entity: PropTypes.object.isRequired
visible: PropTypes.bool.isRequired,
onHide: PropTypes.func.isRequired,
entity: PropTypes.object.isRequired,
}
localValidateFormValues(state) {
@ -64,6 +67,10 @@ class TestUser extends Component {
}
}
async hideModal() {
this.props.onHide();
}
render() {
const t = this.props.t;
@ -76,12 +83,14 @@ class TestUser extends Component {
];
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}>
<TableSelect id="testUser" label={t('previewCampaignAs')} 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>
<TableSelect id="testUser" label={t('Preview as')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.entity.id}`} columns={testUsersColumns} selectionLabelIndex={1} />
</Form>
</ModalDialog>
);
}
}
@ -96,9 +105,17 @@ class TestUser extends Component {
class SendControls extends Component {
constructor(props) {
super(props);
this.state = {
showTestSendModal: false,
previewForTestUserVisible: false
};
this.initForm({
leaveConfirmation: false
});
this.timezoneOptions = moment.tz.names().map(x => [x]);
}
static propTypes = {
@ -111,6 +128,7 @@ class SendControls extends Component {
state.setIn(['date', 'error'], null);
state.setIn(['time', 'error'], null);
state.setIn(['timezone', 'error'], null);
if (state.getIn(['sendLater', 'value'])) {
const dateValue = state.getIn(['date', 'value']).trim();
@ -126,36 +144,54 @@ class SendControls extends Component {
} else if (!moment(timeValue, 'HH:mm', true).isValid()) {
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;
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({
sendLater: true,
date: date.format('YYYY-MM-DD'),
time: date.format('HH:mm')
time: date.format('HH:mm'),
timezone
});
} else {
this.populateFormValues({
sendLater: false,
date: '',
time: ''
time: '',
timezone: moment.tz.guess()
});
}
}
componentDidMount() {
this.populateSendLater();
}
componentDidUpdate(prevProps) {
if (prevProps.entity.scheduled !== this.props.entity.scheduled) {
this.populateSendLater();
}
}
async refreshEntity() {
await this.props.refreshEntity();
}
async postAndMaskStateError(url) {
async postAndMaskStateError(url, data) {
try {
await axios.post(getUrl(url));
await axios.post(getUrl(url), data);
} catch (err) {
if (err instanceof interoperableErrors.InvalidStateError) {
// 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() {
if (this.isFormWithoutErrors()) {
const data = this.getFormValues();
const date = moment(data.date, 'YYYY-MM-DD');
const time = moment(data.time, 'HH:mm');
const dateTime = moment.tz(data.date + ' ' + data.time, 'YYYY-MM-DD HH:mm', data.timezone);
date.hour(time.hour());
date.minute(time.minute());
date.second(0);
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()}`);
await this.postAndMaskStateError(`rest/campaign-start-at/${this.props.entity.id}`, {
startAt: dateTime.valueOf(),
timezone: data.timezone
});
} else {
this.showFormValidation();
@ -204,7 +235,17 @@ class SendControls extends Component {
t('doYouWantToLaunchTheCampaign?'),
async () => {
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 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={[
{ label: t('no'), className: 'btn-primary', onClickAsync: () => this.modalAction(false) },
{ label: t('yes'), className: 'btn-danger', onClickAsync: () => this.modalAction(true) }
]}>
{this.state.modalMessage}
</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)) {
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 (
<div>{yesNoDialog}
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{entity.scheduled ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
{entity.status === CampaignStatus.SCHEDULED ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
</AlignedRow>
<Form stateOwner={this}>
@ -282,75 +361,97 @@ class SendControls extends Component {
<div>
<DatePicker id="date" label={t('date')} />
<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>
}
</Form>
<ButtonRow className={campaignsStyles.sendButtonRow}>
{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`}/>}
{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>
</div>
);
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
return (
<div>{yesNoDialog}
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('campaignIsBeingSentOut')}
</AlignedRow>
<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`}/>
{testButtons}
</ButtonRow>
</div>
);
} else if (entity.status === CampaignStatus.FINISHED) {
const subscrInfo = entity.subscriptionsToSend === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return (
<div>{yesNoDialog}
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('allMessagesSent!HitContinueIfYouYouWant')}
</AlignedRow>
<ButtonRow>
<Button className="btn-primary" icon="play" label={t('continue') + subscrInfo} onClickAsync={::this.confirmStart}/>
<Button className="btn-primary" icon="refresh" label={t('reset')} onClickAsync={::this.resetAsync}/>
<Button className="btn-primary" icon="play" label={t('continue')} onClickAsync={::this.confirmStart}/>
<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`}/>
{testButtons}
</ButtonRow>
</div>
);
} else if (entity.status === CampaignStatus.INACTIVE) {
return (
<div>{yesNoDialog}
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsCurrentlyDisabledClick')}
</AlignedRow>
<ButtonRow>
<Button className="btn-primary" icon="play" label={t('enable')} onClickAsync={::this.enableAsync}/>
{testButtons}
</ButtonRow>
</div>
);
} else if (entity.status === CampaignStatus.ACTIVE) {
return (
<div>{yesNoDialog}
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsEnabledAndSendingMessages')}
</AlignedRow>
<ButtonRow>
<Button className="btn-primary" icon="stop" label={t('disable')} onClickAsync={::this.disableAsync}/>
{testButtons}
</ButtonRow>
</div>
);
} else {
} else {
return null;
}
}
@ -441,7 +542,7 @@ export default class Status extends Component {
addOverridable('from_name', t('fromName'));
addOverridable('from_email', t('fromEmailAddress'));
addOverridable('reply_to', t('replytoEmailAddress'));
addOverridable('subject', t('subjectLine'));
sendSettings.push(<AlignedRow key="subject" label={t('subjectLine')}>{entity.subject}</AlignedRow>);
} else {
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} />
</AlignedRow>
{(entity.type === CampaignType.REGULAR || entity.type === CampaignType.TRIGGERED) &&
<div>
<hr/>
<TestUser entity={entity}/>
</div>
}
<hr/>
<SendControls entity={entity} refreshEntity={::this.refreshEntity}/>

View file

@ -5,13 +5,26 @@ 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 {CheckBox, Dropdown, Form, InputField, TableSelect, withForm} from "../lib/form";
import {withErrorHandling} from "../lib/error-handling";
import {getMailerTypes} from "../send-configurations/helpers";
import axios from '../lib/axios';
import {getUrl} from '../lib/urls';
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([
withTranslation,
@ -27,21 +40,39 @@ export class TestSendModalDialog extends Component {
this.mailerTypes = getMailerTypes(props.t);
this.initForm({
leaveConfirmation: false
leaveConfirmation: false,
onChangeBeforeValidation: {
list: this.onListChanged
}
});
}
static propTypes = {
stateOwner: PropTypes.object,
visible: PropTypes.bool.isRequired,
mode: PropTypes.number.isRequired,
onHide: PropTypes.func.isRequired,
getDataAsync: PropTypes.func.isRequired,
entity: PropTypes.object
getDataAsync: PropTypes.func,
campaign: PropTypes.object
}
onListChanged(mutStateData, key, oldValue, newValue) {
mutStateData.setIn(['segment', 'value'], null);
}
componentDidMount() {
const t = this.props.t;
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() {
const props = this.props;
const t = props.t;
const mode = this.props.mode;
if (this.isFormWithoutErrors()) {
try {
this.hideFormValidation();
this.disableForm();
this.setFormStatusMessage('info', t('sendingTestEmail'));
const data = await this.props.getDataAsync();
const data = {};
const campaignCid = props.entity.cid;
const [listCid, subscriptionCid] = this.getFormValue('testUser').split(':');
if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.TEMPLATE) {
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.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();
@ -87,17 +143,119 @@ export class TestSendModalDialog extends Component {
localValidateFormValues(state) {
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(['testUser', 'error'], t('subscriptionHasToBeSelected'))
} else {
state.setIn(['testUser', 'error'], null);
state.setIn(['listCid', 'error'], null);
state.setIn(['sendConfiguration', 'error'], null);
state.setIn(['testUserSubscriptionCid', '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() {
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 = [
{data: 1, title: t('email')},
{data: 2, title: t('subscriptionId'), render: data => <code>{data}</code>},
@ -106,13 +264,83 @@ export class TestSendModalDialog extends Component {
{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 (
<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 }
{ label: t('close'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}>
<Form stateOwner={this} format="wide">
<TableSelect id="testUser" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.entity.id}`} columns={testUsersColumns} selectionLabelIndex={1} />
{content}
</Form>
</ModalDialog>
);

View file

@ -18,7 +18,8 @@ export function getCampaignLabels(t) {
[CampaignStatus.PAUSED]: t('paused'),
[CampaignStatus.INACTIVE]: t('inactive'),
[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 StatisticsLinkClicks from "./StatisticsLinkClicks";
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) {
const aggLabels = {
@ -28,7 +29,14 @@ function getMenus(t) {
'campaigns': {
title: t('campaigns'),
link: '/campaigns',
panelComponent: CampaignsList,
checkPermissions: {
createCampaign: {
entityTypeId: 'namespace',
requiredOperations: ['createCampaign']
},
...namespaceCheckPermissions('createCampaign')
},
panelRender: props => <CampaignsList permissions={props.permissions}/>,
children: {
':campaignId([0-9]+)': {
title: resolved => t('campaignName', {name: ellipsizeBreadcrumbLabel(resolved.campaign.name)}),
@ -94,7 +102,7 @@ function getMenus(t) {
title: t('edit'),
link: params => `/campaigns/${params.campaignId}/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: {
title: t('content'),
@ -153,15 +161,15 @@ function getMenus(t) {
},
'create-regular': {
title: t('createRegularCampaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.REGULAR} />
panelRender: props => <CampaignsCUD action="create" type={CampaignType.REGULAR} permissions={props.permissions} />
},
'create-rss': {
title: t('createRssCampaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.RSS} />
panelRender: props => <CampaignsCUD action="create" type={CampaignType.RSS} permissions={props.permissions} />
},
'create-triggered': {
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";
export function createComponentMixin(contexts, deps, decoratorFn) {
export function createComponentMixin(opts) {
return {
contexts,
deps,
decoratorFn
contexts: opts.contexts || [],
deps: opts.deps || [],
delegateFuns: opts.delegateFuns || [],
decoratorFn: opts.decoratorFn
};
}
export function withComponentMixins(mixins, delegateFuns) {
const mixinsClosure = new Set();
for (const mixin of mixins) {
console.assert(mixin);
mixinsClosure.add(mixin);
for (const dep of mixin.deps) {
mixinsClosure.add(dep);
@ -34,6 +36,10 @@ export function withComponentMixins(mixins, delegateFuns) {
mixinDelegateFuns.push(...delegateFuns);
}
for (const mixin of mixinsClosure.values()) {
mixinDelegateFuns.push(...mixin.delegateFuns);
}
function TargetClassWithCtors(props) {
if (!new.target) {
throw new TypeError();
@ -55,7 +61,25 @@ export function withComponentMixins(mixins, delegateFuns) {
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 {
render() {
const props = {
@ -70,23 +94,7 @@ export function withComponentMixins(mixins, delegateFuns) {
}
}
let DecoratedInner = 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);
}
}
const DecoratedInner = incorporateMixins(ComponentMixinsInner);
class ComponentMixinsOuter extends React.Component {
constructor(props) {
@ -102,7 +110,7 @@ export function withComponentMixins(mixins, delegateFuns) {
};
return <DecoratedInner {...props}/>
}
};
for (const [propName, Context] of contexts.entries()) {
const existingInnerFn = innerFn;
@ -129,6 +137,35 @@ export function withComponentMixins(mixins, delegateFuns) {
}
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 withErrorHandling = createComponentMixin([{context: ParentErrorHandlerContext, propName: 'parentErrorHandler'}], [], (TargetClass, InnerClass) => {
export const withErrorHandling = createComponentMixin({
contexts: [{context: ParentErrorHandlerContext, propName: 'parentErrorHandler'}],
decoratorFn: (TargetClass, InnerClass) => {
/* Example of use:
this.getFormValuesFromURL(....).catch(error => this.handleError(error));
@ -48,6 +50,7 @@ export const withErrorHandling = createComponentMixin([{context: ParentErrorHand
};
return {};
}
});
export function withAsyncErrorHandler(target, name, descriptor) {

View file

@ -12,7 +12,6 @@ import {TreeSelectMode, TreeTable} from './tree';
import {Table, TableSelectMode} from './table';
import {Button} from "./bootstrap-components";
import {SketchPicker} from 'react-color';
import deepEqual from "fast-deep-equal";
import ACEEditorRaw from 'react-ace';
import 'brace/theme/github';
@ -47,12 +46,15 @@ const FormSendMethod = HTTPMethod;
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 () {
return this.props.formStateOwner;
};
return {};
}
});
export function withFormErrorHandlers(target, name, descriptor) {
@ -333,15 +335,19 @@ class InputField extends Component {
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,
<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 {
static propTypes = {
id: PropTypes.string.isRequired,
@ -353,8 +359,11 @@ class CheckBox extends Component {
}
render() {
return (
<FormStateOwnerContext.Consumer>
{
owner => {
const props = this.props;
const owner = this.getFormStateOwner();
const id = this.props.id;
const htmlId = 'form_' + id;
@ -367,6 +376,12 @@ class CheckBox extends Component {
</div>
);
}
}
</FormStateOwnerContext.Consumer>
);
}
}
@withComponentMixins([
@ -483,6 +498,15 @@ class RadioGroup extends Component {
withFormStateOwner
])
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 = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
@ -500,7 +524,7 @@ class TextArea extends Component {
const className = owner.addFormValidationClass('form-control ' + (props.className || '') , id);
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]),
options: PropTypes.array,
className: PropTypes.string,
format: PropTypes.string
format: PropTypes.string,
disabled: PropTypes.bool
}
render() {
@ -733,7 +758,7 @@ class Dropdown extends Component {
const className = owner.addFormValidationClass('form-control ' + (props.className || '') , id);
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}
</select>
);
@ -793,7 +818,7 @@ class TreeTableSelect extends Component {
dataUrl: PropTypes.string,
data: PropTypes.array,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
format: PropTypes.string
format: PropTypes.string,
}
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 cleanFormState = Immutable.Map({
@ -1044,6 +1070,7 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
axios.post(getUrl(settings.serverValidation.url), payload)
.then(response => {
if (self.isComponentMounted()) {
self.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
mutState.set('isServerValidationRunning', false);
@ -1062,8 +1089,10 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
}));
scheduleValidateForm(self);
}
})
.catch(error => {
if (self.isComponentMounted()) {
console.log('Error in "validateFormState": ' + error);
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
// scheduleValidateForm(self);
}
});
} else {
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) {
const state = this.state || {};
state.formState = cleanFormState;
@ -1220,7 +1270,9 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
proto.waitForFormServerValidated = async function () {
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 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 () {
@ -1500,6 +1569,7 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
};
return {};
}
});
function filterData(obj, allowedKeys) {

View file

@ -1,8 +1,8 @@
'use strict';
import React from 'react';
import {I18nextProvider, withNamespaces} from 'react-i18next';
import i18n from 'i18next';
import {withNamespaces} from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector';
import mailtrainConfig from 'mailtrainConfig';
@ -63,12 +63,30 @@ i18n
export default i18n;
export const withTranslation = createComponentMixin([], [], (TargetClass, InnerClass) => {
return {
cls: withNamespaces()(TargetClass)
};
export const TranslationContext = React.createContext(null);
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) {
return key;
}

View file

@ -89,7 +89,7 @@ export class RestActionModalDialog extends Component {
const t = this.props.t;
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('yes'), className: 'btn-danger', onClickAsync: ::this.performAction }
]}>
@ -108,22 +108,30 @@ const entityTypeLabels = {
'sendConfiguration': t => t('sendConfiguration'),
'report': t => t('report'),
'reportTemplate': t => t('reportTemplate'),
'mosaicoTemplate': t => t('mosaicoTemplate')
'mosaicoTemplate': t => t('mosaicoTemplate'),
'user': t => t('User')
};
function _getDependencyErrorMessage(err, t, name) {
return (
<div>
{err.data.dependencies.length > 0 ?
<>
<p>{t('cannoteDeleteNameDueToTheFollowing', {name})}</p>
<ul className={styles.errorsList}>
{err.data.dependencies.map(dep =>
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
<li key={dep.id}>{entityTypeLabels[dep.entityTypeId](t)}: [{dep.id}]</li>
)}
{err.data.andMore && <li>{t('andMore')}</li>}
</ul>
</>
:
<p>{t('Cannot delete {{name}} due to hidden dependencies', {name})}</p>
}
</div>
);
}
@ -143,10 +151,11 @@ export class DeleteModalDialog extends Component {
visible: PropTypes.bool.isRequired,
stateOwner: PropTypes.object.isRequired,
deleteUrl: PropTypes.string.isRequired,
backUrl: PropTypes.string,
successUrl: PropTypes.string,
backUrl: PropTypes.string.isRequired,
successUrl: PropTypes.string.isRequired,
deletingMsg: PropTypes.string.isRequired,
deletedMsg: PropTypes.string.isRequired
deletedMsg: PropTypes.string.isRequired,
name: PropTypes.string
}
async onErrorAsync(err) {
@ -171,7 +180,7 @@ export class DeleteModalDialog extends Component {
render() {
const t = this.props.t;
const owner = this.props.stateOwner;
const name = owner.getFormValue('name') || '';
const name = this.props.name || owner.getFormValue('name') || '';
return <RestActionModalDialog
title={t('confirmDeletion')}

View file

@ -9,7 +9,7 @@ import {withComponentMixins} from "./decorator-helpers";
@withComponentMixins([
withTranslation
])
class NamespaceSelect extends Component {
export class NamespaceSelect extends Component {
render() {
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'])) {
state.setIn(['namespace', 'error'], t('namespaceMustBeSelected'));
} else {
@ -27,7 +27,25 @@ function validateNamespace(t, state) {
}
}
export {
NamespaceSelect,
validateNamespace
export function getDefaultNamespace(permissions) {
return permissions.viewUsersNamespace && permissions.createEntityInUsersNamespace ? mailtrainConfig.user.namespace : null;
}
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 {withTranslation} from "./i18n";
import shallowEqual from "shallowequal";
import {checkPermissions} from "./permissions";
async function resolve(route, match, prevResolvedByUrl) {
async function resolve(route, match, prevResolverState) {
const resolved = {};
const resolvedByUrl = {};
const keysToGo = new Set(Object.keys(route.resolve));
const permissions = {};
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) {
const urlsToResolve = [];
@ -60,10 +70,10 @@ async function resolve(route, match, prevResolvedByUrl) {
const key = keysToResolve[idx];
const url = urlsToResolve[idx];
if (url in prevResolvedByUrl) {
const entity = prevResolvedByUrl[url];
if (url in prevResolverState.resolvedByUrl) {
const entity = prevResolverState.resolvedByUrl[url];
resolved[key] = entity;
resolvedByUrl[url] = entity;
resolverState.resolvedByUrl[url] = entity;
} else {
urlsToResolveByRest.push(url);
@ -83,7 +93,7 @@ async function resolve(route, match, prevResolvedByUrl) {
for (let idx = 0; idx < keysToResolveByRest.length; idx++) {
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);
}
}
}
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) {
function _getRoutes(urlPrefix, resolve, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) {
function _getRoutes(urlPrefix, resolve, checkPermissions, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) {
let routes = [];
for (let routeKey in structure) {
const entry = structure[routeKey];
@ -115,6 +163,13 @@ export function getRoutes(structure, parentRoute) {
entryResolve = resolve;
}
let entryCheckPermissions;
if (entry.checkPermissions) {
entryCheckPermissions = Object.assign({}, checkPermissions, entry.checkPermissions);
} else {
entryCheckPermissions = checkPermissions;
}
let navKeys;
const entryNavs = [];
if (entry.navs) {
@ -145,6 +200,7 @@ export function getRoutes(structure, parentRoute) {
panelInFullScreen: entry.panelInFullScreen,
insideIframe: entry.insideIframe,
resolve: entryResolve,
checkPermissions: entryCheckPermissions,
parents,
navs: [...navs, ...entryNavs],
@ -167,12 +223,12 @@ export function getRoutes(structure, parentRoute) {
const childNavs = [...entryNavs];
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) {
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 || {}) }
};
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 {
return _getRoutes('', {}, [], { "": structure }, [], null, null);
return _getRoutes('', {}, {}, [], { "": structure }, [], null, null);
}
}
@ -209,11 +265,13 @@ export class Resolver extends Component {
this.state = {
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.permissions = {};
}
}
@ -228,28 +286,31 @@ export class Resolver extends Component {
async resolve(prevMatch) {
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({
resolved: {},
resolvedByUrl: {}
permissions: {},
resolverState: null
});
} else {
const prevResolvedByUrl = this.state.resolvedByUrl;
const prevResolverState = this.state.resolverState;
if (this.state.resolved) {
if (this.state.resolverState) {
this.setState({
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.
this.setState({
resolved,
resolvedByUrl
permissions,
resolverState
});
}
}
@ -272,7 +333,7 @@ export class Resolver extends Component {
}
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 params = this.props.match.params;
const render = resolved => {
if (resolved) {
const subStructure = route.structure(resolved, params);
const render = (resolved, permissions) => {
if (resolved && permissions) {
const subStructure = route.structure(resolved, permissions, params);
const routes = getRoutes(subStructure, route);
const _renderRoute = route => {
@ -354,7 +415,10 @@ export function renderRoute(route, panelRouteCtor, loadingMessageFn, flashMessag
}
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) {
return this.props.sectionContent.setFlashMessage(severity, text);
};
@ -380,4 +444,5 @@ export const withPageHelpers = createComponentMixin([{context: SectionContentCon
};
return {};
}
});

View file

@ -268,16 +268,17 @@ class PanelRoute extends Component {
const panelInFullScreen = this.state.panelInFullScreen;
const render = resolved => {
const render = (resolved, permissions) => {
let primaryMenu = null;
let secondaryMenu = null;
let content = null;
if (resolved) {
if (resolved && permissions) {
const compProps = {
match: this.props.match,
location: this.props.location,
resolved,
permissions,
setPanelInFullScreen: this.setPanelInFullScreen,
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 {
constructor(props) {
super(props);
@ -698,6 +701,7 @@ export const requiresAuthenticatedUser = createComponentMixin([], [withPageHelpe
return {
cls: RequiresAuthenticatedUser
};
}
});
export function getLanguageChooser(t) {

View file

@ -3,10 +3,6 @@
import {getUrl} from "./urls";
import axios from "./axios";
async function checkPermissions(request) {
export async function checkPermissions(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 ReactDOM from 'react-dom';
import {I18nextProvider} from 'react-i18next';
import i18n, {withTranslation} from './i18n';
import {TranslationRoot, withTranslation} from './i18n';
import {parentRPC, UntrustedContentRoot} from './untrusted';
import PropTypes from "prop-types";
import styles from "./sandboxed-ckeditor.scss";
@ -126,9 +125,9 @@ export default function() {
parentRPC.init();
ReactDOM.render(
<I18nextProvider i18n={ i18n }>
<TranslationRoot>
<UntrustedContentRoot render={props => <CKEditorSandbox {...props} />} />
</I18nextProvider>,
</TranslationRoot>,
document.getElementById('root')
);
};

View file

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

View file

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

View file

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

View file

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

View file

@ -33,8 +33,10 @@ class TreeTable extends Component {
constructor(props) {
super(props);
this.mounted = false;
this.state = {
treeData: []
treeData: null
};
if (props.data) {
@ -50,9 +52,9 @@ class TreeTable extends Component {
}
refresh() {
if (this.tree) {
this.tree.reload(this.sanitizeTreeData(this.state.treeData));
this.updateSelection();
if (this.tree && !this.props.data && this.props.dataUrl) {
// noinspection JSIgnoredPromiseFromCall
this.loadData();
}
}
@ -68,10 +70,12 @@ class TreeTable extends Component {
}
}
if (this.mounted) {
this.setState({
treeData
});
}
}
static propTypes = {
dataUrl: PropTypes.string,
@ -95,6 +99,7 @@ class TreeTable extends Component {
// XSS protection
sanitizeTreeData(unsafeData) {
const data = [];
if (unsafeData) {
for (const unsafeEntry of unsafeData) {
const entry = Object.assign({}, unsafeEntry);
entry.unsanitizedTitle = entry.title;
@ -105,10 +110,14 @@ class TreeTable extends Component {
}
data.push(entry);
}
}
return data;
}
componentDidMount() {
this.mounted = true;
if (!this.props.data && this.props.dataUrl) {
// noinspection JSIgnoredPromiseFromCall
this.loadData();
@ -187,6 +196,7 @@ class TreeTable extends Component {
createNode: createNodeFn,
checkbox: this.selectMode === TreeSelectMode.MULTI,
activate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null),
deactivate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null),
select: (this.selectMode === TreeSelectMode.MULTI ? ::this.onSelect : null),
};
@ -221,6 +231,10 @@ class TreeTable extends Component {
}
}
componentWillUnmount() {
this.mounted = false;
}
updateSelection() {
const tree = this.tree;
if (this.selectMode === TreeSelectMode.MULTI) {
@ -231,7 +245,22 @@ class TreeTable extends Component {
tree.enableUpdate(true);
} 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
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) {
// noinspection JSIgnoredPromiseFromCall
@ -288,7 +318,7 @@ class TreeTable extends Component {
if (updated) {
// noinspection JSIgnoredPromiseFromCall
this.onSelectionChanged(selection);
this.onSelectionChanged(newSel);
}
}

View file

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

View file

@ -3,13 +3,13 @@
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
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 {Icon} from "../lib/bootstrap-components";
import {checkPermissions} from "../lib/permissions";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers";
import {withForm} from "../lib/form";
import PropTypes from 'prop-types';
@withComponentMixins([
withTranslation,
@ -26,28 +26,17 @@ export default class List extends Component {
tableRestActionDialogInit(this);
}
@withAsyncErrorHandler
async fetchPermissions() {
const result = await checkPermissions({
createList: {
entityTypeId: 'namespace',
requiredOperations: ['createList']
}
});
this.setState({
createPermitted: result.data.createList
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
static propTypes = {
permissions: PropTypes.object
}
render() {
const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createList;
const customFormsPermitted = permissions.createCustomForm || permissions.viewCustomForm;
const columns = [
{
data: 1,
@ -130,12 +119,14 @@ export default class List extends Component {
return (
<div>
{tableRestActionDialogRender(this)}
{this.state.createPermitted &&
<Toolbar>
{ createPermitted &&
<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>

View file

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

View file

@ -10,6 +10,7 @@ import {
AlignedRow,
Button,
ButtonRow,
CheckBox,
Dropdown,
Fieldset,
filterData,
@ -22,7 +23,7 @@ import {
withFormErrorHandlers
} from '../../lib/form';
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 mailtrainConfig from 'mailtrainConfig';
import {getTrustedUrl, getUrl} from "../../lib/urls";
@ -279,7 +280,8 @@ export default class CUD extends Component {
static propTypes = {
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) {
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_confirm_subscription_notice',
'mail_confirm_subscription_html',
@ -330,8 +335,10 @@ export default class CUD extends Component {
const data = {
name: '',
description: '',
fromExistingEntity: false,
existingEntity: null,
selectedTemplate: 'layout',
namespace: mailtrainConfig.user.namespace
namespace: getDefaultNamespace(this.props.permissions)
};
this.supplyDefaults(data);
@ -350,6 +357,13 @@ export default class CUD extends Component {
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;
const formsErrors = [];
@ -386,10 +400,10 @@ export default class CUD extends Component {
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/forms/${this.props.entity.id}`
url = `rest/forms/${this.props.entity.id}`;
} else {
sendMethod = FormSendMethod.POST;
url = 'rest/forms'
url = 'rest/forms';
}
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 = [
{ data: 0, title: "#" },
{ data: 1, title: t('name') },
@ -488,13 +508,21 @@ export default class CUD extends Component {
<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')}>
<TableSelect id="previewList" label={t('listToPreviewOn')} withHeader dropdown dataUrl='rest/lists-table' columns={listsColumns} selectionLabelIndex={1} help={t('selectListWhoseFieldsWillBeUsedToPreview')}/>
{ previewListId &&
<div>
<AlignedRow>
<div className="help-block">
<div>
<small>
{t('noteTheseLinksAreSolelyForAQuickPreview')}
</small>
@ -549,6 +577,8 @@ export default class CUD extends Component {
<ACEEditor id={selectedTemplate} height="500px" mode={this.templateSettings[selectedTemplate].mode}/>
</Fieldset>
}
</>
}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>

View file

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

View file

@ -19,13 +19,29 @@ import ImportRunsStatus from './imports/RunStatus';
import Share from '../shares/Share';
import TriggersList from './TriggersList';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) {
return {
'lists': {
title: t('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: {
':listId([0-9]+)': {
title: resolved => t('listName', {name: ellipsizeBreadcrumbLabel(resolved.list.name)}),
@ -70,7 +86,7 @@ function getMenus(t) {
title: t('edit'),
link: params => `/lists/${params.listId}/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: {
title: t('fields'),
@ -191,12 +207,15 @@ function getMenus(t) {
},
create: {
title: t('create'),
panelRender: props => <ListsCUD action="create" />
panelRender: props => <ListsCUD action="create" permissions={props.permissions} />
},
forms: {
title: t('customForms-1'),
link: '/lists/forms',
panelComponent: FormsList,
checkPermissions: {
...namespaceCheckPermissions('createCustomForm')
},
panelRender: props => <FormsList permissions={props.permissions}/>,
children: {
':formsId([0-9]+)': {
title: resolved => t('customFormsName', {name: ellipsizeBreadcrumbLabel(resolved.forms.name)}),
@ -209,7 +228,7 @@ function getMenus(t) {
title: t('edit'),
link: params => `/lists/forms/${params.formsId}/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: {
title: t('share'),
@ -221,7 +240,7 @@ function getMenus(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']
},
});
this.timezoneOptions = [
{ key: '', label: t('notSelected') },
...moment.tz.names().map(tz => ({ key: tz.toLowerCase(), label: tz }))
];
}
static propTypes = {
@ -185,11 +190,6 @@ export default class CUD extends Component {
const statusOptions = Object.keys(this.subscriptionStatusLabels)
.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 = [];
for (const fld of this.props.fieldsGrouped) {
customFields.push(this.fieldTypes[fld.type].form(fld));
@ -219,10 +219,9 @@ export default class CUD extends Component {
<InputField id="email" label={t('email')}/>
{customFields}
<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}/>

View file

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

View file

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

View file

@ -4,12 +4,13 @@ import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
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 {checkPermissions} from "../lib/permissions";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {getGlobalNamespaceId} from "../../../shared/namespaces";
import {withComponentMixins} from "../lib/decorator-helpers";
import mailtrainConfig from 'mailtrainConfig';
import PropTypes from 'prop-types';
@withComponentMixins([
withTranslation,
@ -25,28 +26,16 @@ export default class List extends Component {
tableRestActionDialogInit(this);
}
@withAsyncErrorHandler
async fetchPermissions() {
const result = await checkPermissions({
createNamespace: {
entityTypeId: 'namespace',
requiredOperations: ['createNamespace']
}
});
this.setState({
createPermitted: result.data.createNamespace
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchPermissions();
static propTypes = {
permissions: PropTypes.object
}
render() {
const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createNamespace;
const actions = node => {
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'));
}
@ -74,7 +64,7 @@ export default class List extends Component {
return (
<div>
{tableRestActionDialogRender(this)}
{this.state.createPermitted &&
{createPermitted &&
<Toolbar>
<LinkButton to="/namespaces/create" className="btn-primary" icon="plus" label={t('createNamespace')}/>
</Toolbar>

View file

@ -5,13 +5,21 @@ import CUD from './CUD';
import List from './List';
import Share from '../shares/Share';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) {
return {
namespaces: {
title: t('namespaces'),
link: '/namespaces',
panelComponent: List,
checkPermissions: {
createNamespace: {
entityTypeId: 'namespace',
requiredOperations: ['createNamespace']
},
...namespaceCheckPermissions('createNamespace')
},
panelRender: props => <List permissions={props.permissions}/>,
children: {
':namespaceId([0-9]+)': {
title: resolved => t('namespaceName', {name: ellipsizeBreadcrumbLabel(resolved.namespace.name)}),
@ -24,7 +32,7 @@ function getMenus(t) {
title: t('edit'),
link: params => `/namespaces/${params.namespaceId}/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: {
title: t('share'),
@ -36,7 +44,7 @@ function getMenus(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 {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import moment from 'moment';
import {NamespaceSelect, validateNamespace} from '../lib/namespace';
import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import {getUrl} from "../lib/urls";
import {withComponentMixins} from "../lib/decorator-helpers";
@ -49,7 +48,8 @@ export default class CUD extends Component {
static propTypes = {
action: PropTypes.string.isRequired,
entity: PropTypes.object
entity: PropTypes.object,
permissions: PropTypes.object
}
@withAsyncErrorHandler
@ -97,7 +97,7 @@ export default class CUD extends Component {
name: '',
description: '',
report_template: null,
namespace: mailtrainConfig.user.namespace,
namespace: getDefaultNamespace(this.props.permissions),
user_fields: null
});
}

View file

@ -9,10 +9,10 @@ import moment from 'moment';
import axios from '../lib/axios';
import {ReportState} from '../../../shared/reports';
import {Icon} from "../lib/bootstrap-components";
import {checkPermissions} from "../lib/permissions";
import {getUrl} from "../lib/urls";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers";
import PropTypes from 'prop-types';
@withComponentMixins([
withTranslation,
@ -28,36 +28,8 @@ export default class List extends Component {
tableRestActionDialogInit(this);
}
@withAsyncErrorHandler
async fetchPermissions() {
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();
static propTypes = {
permissions: PropTypes.object
}
@withAsyncErrorHandler
@ -75,6 +47,10 @@ export default class List extends Component {
render() {
const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createReport && permissions.executeReportTemplate;
const templatesPermitted = permissions.createReportTemplate || permissions.viewReportTemplate;
const columns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('template') },
@ -176,10 +152,10 @@ export default class List extends Component {
<div>
{tableRestActionDialogRender(this)}
<Toolbar>
{this.state.createPermitted &&
{createPermitted &&
<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')}/>
}
</Toolbar>

View file

@ -10,6 +10,7 @@ import Share from '../shares/Share';
import {ReportState} from '../../../shared/reports';
import mailtrainConfig from 'mailtrainConfig';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) {
@ -17,7 +18,26 @@ function getMenus(t) {
'reports': {
title: t('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: {
':reportId([0-9]+)': {
title: resolved => t('reportName', {name: ellipsizeBreadcrumbLabel(resolved.report.name)}),
@ -30,7 +50,7 @@ function getMenus(t) {
title: t('edit'),
link: params => `/reports/${params.reportId}/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: {
title: t('view'),
@ -59,12 +79,15 @@ function getMenus(t) {
},
create: {
title: t('create'),
panelRender: props => <ReportsCUD action="create" />
panelRender: props => <ReportsCUD action="create" permissions={props.permissions} />
},
templates: {
title: t('templates'),
link: '/reports/templates',
panelComponent: ReportTemplatesList,
checkPermissions: {
...namespaceCheckPermissions('createReportTemplate')
},
panelRender: props => <ReportTemplatesList permissions={props.permissions}/>,
children: {
':templateId([0-9]+)': {
title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}),
@ -77,7 +100,7 @@ function getMenus(t) {
title: t('edit'),
link: params => `/reports/templates/${params.templateId}/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: {
title: t('share'),
@ -90,7 +113,7 @@ function getMenus(t) {
create: {
title: t('create'),
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
} from '../../lib/form';
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 mailtrainConfig from 'mailtrainConfig';
import 'brace/mode/javascript';
import 'brace/mode/json';
import 'brace/mode/handlebars';
@ -46,7 +45,8 @@ export default class CUD extends Component {
static propTypes = {
action: PropTypes.string.isRequired,
wizard: PropTypes.string,
entity: PropTypes.object
entity: PropTypes.object,
permissions: PropTypes.object
}
submitFormValuesMutator(data) {
@ -64,7 +64,7 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
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',
user_fields:
'[\n' +
@ -114,7 +114,7 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
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',
user_fields:
'[\n' +
@ -144,7 +144,7 @@ export default class CUD extends Component {
this.populateFormValues({
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.)',
namespace: mailtrainConfig.user.namespace,
namespace: getDefaultNamespace(this.props.permissions),
mime_type: 'text/html',
user_fields:
'[\n' +
@ -215,7 +215,7 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
description: '',
namespace: mailtrainConfig.user.namespace,
namespace: getDefaultNamespace(this.props.permissions),
mime_type: 'text/html',
user_fields: '',
js: '',

View file

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

View file

@ -4,8 +4,7 @@ import './lib/public-path';
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import {I18nextProvider} from 'react-i18next';
import i18n, {withTranslation} from './lib/i18n';
import {TranslationRoot, withTranslation} from './lib/i18n';
import account from './account/root';
import login from './login/root';
import blacklist from './blacklist/root';
@ -139,7 +138,7 @@ class Root extends Component {
}
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
} from '../lib/form';
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 {getMailerTypes} from "./helpers";
@ -60,7 +60,8 @@ export default class CUD extends Component {
static propTypes = {
action: PropTypes.string.isRequired,
wizard: PropTypes.string,
entity: PropTypes.object
entity: PropTypes.object,
permissions: PropTypes.object
}
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',
'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']);
}
@ -96,15 +97,13 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
description: '',
namespace: mailtrainConfig.user.namespace,
namespace: getDefaultNamespace(this.props.permissions),
from_email: '',
from_email_overridable: false,
from_name: '',
from_name_overridable: false,
reply_to: '',
reply_to_overridable: false,
subject: '',
subject_overridable: false,
verpEnabled: false,
verp_hostname: '',
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}/>
<InputField id="reply_to" label={t('defaultReplytoEmail')}/>
<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')}/>
</Fieldset>

View file

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

View file

@ -21,7 +21,7 @@ export function getMailerTypes(t) {
const initVals = mailerTypes[mailerType].initData();
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]);
}
}

View file

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

View file

@ -20,14 +20,14 @@ import {
withFormErrorHandlers
} from '../lib/form';
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 mailtrainConfig from 'mailtrainConfig';
import {getEditForm, getTemplateTypes, getTypeForm} from './helpers';
import {getEditForm, getTagLanguages, getTemplateTypes, getTypeForm} from './helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
import {TestSendModalDialog} from "./TestSendModalDialog";
import {TestSendModalDialog, TestSendModalDialogMode} from "../campaigns/TestSendModalDialog";
import {withComponentMixins} from "../lib/decorator-helpers";
import moment from 'moment';
@ -44,6 +44,7 @@ export default class CUD extends Component {
super(props);
this.templateTypes = getTemplateTypes(props.t);
this.tagLanguages = getTagLanguages(props.t);
this.state = {
showMergeTagReference: false,
@ -57,7 +58,8 @@ export default class CUD extends Component {
this.initForm({
getPreSubmitUpdater: ::this.getPreSubmitFormValuesUpdater,
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,
wizard: PropTypes.string,
entity: PropTypes.object,
permissions: PropTypes.object,
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) {
this.templateTypes[data.type].afterLoad(data);
}
submitFormValuesMutator(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() {
@ -113,11 +125,12 @@ export default class CUD extends Component {
this.populateFormValues({
name: '',
description: '',
namespace: mailtrainConfig.user.namespace,
namespace: getDefaultNamespace(this.props.permissions),
type: mailtrainConfig.editors[0],
tag_language: mailtrainConfig.tagLanguages[0],
fromSourceTemplate: false,
sourceTemplate: null,
fromExistingEntity: false,
existingEntity: null,
text: '',
html: '',
@ -143,10 +156,14 @@ export default class CUD extends Component {
state.setIn(['type', 'error'], t('typeMustBeSelected'));
}
if (state.getIn(['fromSourceTemplate', 'value']) && !state.getIn(['sourceTemplate', 'value'])) {
state.setIn(['sourceTemplate', 'error'], t('sourceTemplateMustNotBeEmpty'));
if (!state.getIn(['tag_language', 'value'])) {
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 {
state.setIn(['sourceTemplate', 'error'], null);
state.setIn(['existingEntity', 'error'], null);
}
validateNamespace(t, state);
@ -247,7 +264,8 @@ export default class CUD extends Component {
return {
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});
}
const tagLanguageOptions = [];
for (const key of mailtrainConfig.tagLanguages) {
tagLanguageOptions.push({key, label: this.tagLanguages[key].name});
}
const typeKey = this.getFormValue('type');
let editForm = null;
@ -290,14 +313,16 @@ export default class CUD extends Component {
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('type'), render: data => this.templateTypes[data].typeName },
{ data: 4, title: t('created'), render: data => moment(data).fromNow() },
{ data: 5, title: t('namespace') },
{ data: 4, title: t('Tag language'), render: data => this.tagLanguages[data].name },
{ data: 5, title: t('created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('namespace') },
];
return (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
{isEdit &&
<TestSendModalDialog
mode={TestSendModalDialogMode.TEMPLATE}
visible={this.state.showTestSendModal}
onHide={() => this.setState({showTestSendModal: false})}
getDataAsync={this.sendModalGetDataHandler}/>
@ -327,11 +352,11 @@ export default class CUD extends Component {
<TextArea id="description" label={t('description')}/>
{!isEdit &&
<CheckBox id="fromSourceTemplate" label={t('template')} text={t('cloneFromAnExistingTemplate')}/>
<CheckBox id="fromExistingEntity" label={t('template')} text={t('cloneFromAnExistingTemplate')}/>
}
{this.getFormValue('fromSourceTemplate') ?
<TableSelect key="templateSelect" id="sourceTemplate" withHeader dropdown dataUrl='rest/templates-table' columns={templatesColumns} selectionLabelIndex={1} />
{this.getFormValue('fromExistingEntity') ?
<TableSelect id="existingEntity" label={t('Source template')} withHeader dropdown dataUrl='rest/templates-table' columns={templatesColumns} selectionLabelIndex={1} />
:
<>
{isEdit ?
@ -343,6 +368,8 @@ export default class CUD extends Component {
}
{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 {Icon} from '../lib/bootstrap-components';
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 moment from 'moment';
import {getTemplateTypes} from './helpers';
import {checkPermissions} from "../lib/permissions";
import {getTagLanguages, getTemplateTypes} from './helpers';
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers";
import PropTypes from 'prop-types';
@withComponentMixins([
withTranslation,
@ -23,52 +24,34 @@ export default class List extends Component {
super(props);
this.templateTypes = getTemplateTypes(props.t);
this.tagLanguages = getTagLanguages(props.t);
this.state = {};
tableRestActionDialogInit(this);
}
@withAsyncErrorHandler
async fetchPermissions() {
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();
static propTypes = {
permissions: PropTypes.object
}
render() {
const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createTemplate;
const mosaicoTemplatesPermitted = permissions.createMosaicoTemplate || permissions.viewMosaicoTemplate;
const columns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('type'), render: data => this.templateTypes[data].typeName },
{ data: 4, title: t('created'), render: data => moment(data).fromNow() },
{ data: 5, title: t('namespace') },
{ data: 4, title: t('Tag language'), render: data => this.tagLanguages[data].name },
{ data: 5, title: t('created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('namespace') },
{
actions: data => {
const actions = [];
const perms = data[6];
const perms = data[7];
if (perms.includes('edit')) {
actions.push({
@ -102,10 +85,10 @@ export default class List extends Component {
<div>
{tableRestActionDialogRender(this)}
<Toolbar>
{this.state.createPermitted &&
{createPermitted &&
<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')}/>
}
</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 {ActionLink, Button} from "../lib/bootstrap-components";
import {Trans} from "react-i18next";
import {TagLanguages, renderTag} from "../../../shared/templates";
import styles from "../lib/styles.scss";
export const ResourceType = {
TEMPLATE: 'template',
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) {
@ -67,23 +79,29 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
render: data => mosaicoTemplateTypes[data].typeName
},
{
data: 5,
data: 6,
title: t('namespace')
},
];
templateTypes.mosaico = {
typeName: t('mosaico'),
getTypeForm: (owner, isEdit) =>
<TableSelect
getTypeForm: (owner, isEdit) => {
const tagLanguageKey = owner.getFormValue(prefix + 'tag_language');
if (tagLanguageKey) {
return <TableSelect
id={prefix + 'mosaicoTemplate'}
label={t('mosaicoTemplate')}
withHeader
dropdown
dataUrl='rest/mosaico-templates-table'
dataUrl={`rest/mosaico-templates-by-tag-language-table/${tagLanguageKey}`}
columns={mosaicoTemplatesColumns}
selectionLabelIndex={1}
disabled={isEdit}/>,
disabled={isEdit}/>
} else {
return null;
}
},
getHTMLEditor: owner =>
<AlignedRow
label={t('templateContentHtml')}>
@ -144,6 +162,12 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'mosaico');
},
afterTagLanguageChange: (mutState, isEdit) => {
if (!isEdit) {
mutState.setIn([prefix + 'mosaicoTemplate', 'value'], null);
}
},
isTagLanguageSelectorDisabledForEdit: true,
validate: state => {
const mosaicoTemplate = state.getIn([prefix + 'mosaicoTemplate', 'value']);
if (!mosaicoTemplate) {
@ -230,6 +254,9 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'mosaicoWithFsTemplate');
},
afterTagLanguageChange: (mutState, isEdit) => {
},
isTagLanguageSelectorDisabledForEdit: false,
validate: state => {
}
};
@ -317,6 +344,9 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'grapesjs');
},
afterTagLanguageChange: (mutState, isEdit) => {
},
isTagLanguageSelectorDisabledForEdit: false,
validate: state => {
}
};
@ -376,6 +406,9 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'ckeditor4');
},
afterTagLanguageChange: (mutState, isEdit) => {
},
isTagLanguageSelectorDisabledForEdit: false,
validate: state => {
}
};
@ -460,6 +493,9 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
afterTypeChange: mutState => {
initFieldsIfMissing(mutState, 'codeeditor');
},
afterTagLanguageChange: (mutState, isEdit) => {
},
isTagLanguageSelectorDisabledForEdit: false,
validate: state => {
}
};
@ -471,6 +507,39 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
export function getEditForm(owner, typeKey, prefix = '') {
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 (
<div>
<AlignedRow>
@ -480,12 +549,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
label={t('mergeTagReference')}/>
{owner.state.showMergeTagReference &&
<div style={{marginTop: '15px'}}>
<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>[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>
{instructions}
<table className="table table-bordered table-condensed table-striped">
<thead>
<tr>
@ -500,7 +564,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
<tbody>
<tr>
<th scope="row">
[LINK_UNSUBSCRIBE]
{tg('LINK_UNSUBSCRIBE')}
</th>
<td>
<Trans i18nKey="urlThatPointsToTheUnsubscribePage">URL that points to the unsubscribe page</Trans>
@ -508,7 +572,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr>
<tr>
<th scope="row">
[LINK_PREFERENCES]
{tg('LINK_PREFERENCES')}
</th>
<td>
<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>
<th scope="row">
[LINK_BROWSER]
{tg('LINK_BROWSER')}
</th>
<td>
<Trans i18nKey="urlToPreviewTheMessageInABrowser">URL to preview the message in a browser</Trans>
@ -524,7 +588,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr>
<tr>
<th scope="row">
[EMAIL]
{tg('EMAIL')}
</th>
<td>
<Trans i18nKey="emailAddress-1">Email address</Trans>
@ -532,7 +596,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr>
<tr>
<th scope="row">
[SUBSCRIPTION_ID]
{tg('SUBSCRIPTION_ID')}
</th>
<td>
<Trans i18nKey="uniqueIdThatIdentifiesTheRecipient">Unique ID that identifies the recipient</Trans>
@ -540,7 +604,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr>
<tr>
<th scope="row">
[LIST_ID]
{tg('LIST_ID')}
</th>
<td>
<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>
<th scope="row">
[CAMPAIGN_ID]
{tg('CAMPAIGN_ID')}
</th>
<td>
<Trans i18nKey="uniqueIdThatIdentifiesCurrentCampaign">Unique ID that identifies current campaign</Trans>
@ -573,7 +637,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
<tbody>
<tr>
<th scope="row">
[RSS_ENTRY_TITLE]
{tg('RSS_ENTRY_TITLE')}
</th>
<td>
<Trans i18nKey="rssEntryTitle">RSS entry title</Trans>
@ -581,7 +645,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr>
<tr>
<th scope="row">
[RSS_ENTRY_DATE]
{tg('RSS_ENTRY_DATE')}
</th>
<td>
<Trans i18nKey="rssEntryDate">RSS entry date</Trans>
@ -589,7 +653,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr>
<tr>
<th scope="row">
[RSS_ENTRY_LINK]
{tg('RSS_ENTRY_LINK')}
</th>
<td>
<Trans i18nKey="rssEntryLink">RSS entry link</Trans>
@ -597,7 +661,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr>
<tr>
<th scope="row">
[RSS_ENTRY_CONTENT]
{tg('RSS_ENTRY_CONTENT')}
</th>
<td>
<Trans i18nKey="contentOfAnRssEntry">Content of an RSS entry</Trans>
@ -605,7 +669,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr>
<tr>
<th scope="row">
[RSS_ENTRY_SUMMARY]
{tg('RSS_ENTRY_SUMMARY')}
</th>
<td>
<Trans i18nKey="rssEntrySummary">RSS entry summary</Trans>
@ -613,7 +677,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
</tr>
<tr>
<th scope="row">
[RSS_ENTRY_IMAGE_URL]
{tg('RSS_ENTRY_IMAGE_URL')}
</th>
<td>
<Trans i18nKey="rssEntryImageUrl">RSS entry image URL</Trans>

View file

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

View file

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

View file

@ -20,9 +20,7 @@ export function getTemplateTypes(t) {
return owner.getFormValue('mjml') || '';
}
function generateHtmlFromMjml(owner) {
const mjml = getMjml(owner);
function generateHtmlFromMjml(mjml) {
try {
const res = mjml2html(mjml);
return res.html;
@ -88,7 +86,7 @@ export function getTemplateTypes(t) {
typeName: t('mjml'),
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')}/>
</>
),
@ -98,7 +96,7 @@ export function getTemplateTypes(t) {
beforeSave: (owner, data) => {
data.data = {
mjml: data.mjml,
html: generateHtmlFromMjml(owner)
html: generateHtmlFromMjml(data.mjml)
};
clearBeforeSend(data);

View file

@ -9,14 +9,29 @@ import Files from "../lib/files";
import MosaicoCUD from './mosaico/CUD';
import MosaicoList from './mosaico/List';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) {
return {
'templates': {
title: t('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: {
':templateId([0-9]+)': {
title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}),
@ -29,7 +44,7 @@ function getMenus(t) {
title: t('edit'),
link: params => `/templates/${params.templateId}/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: {
title: t('files'),
@ -47,12 +62,15 @@ function getMenus(t) {
},
create: {
title: t('create'),
panelRender: props => <TemplatesCUD action="create" />
panelRender: props => <TemplatesCUD action="create" permissions={props.permissions} />
},
mosaico: {
title: t('mosaicoTemplates'),
link: '/templates/mosaico',
panelComponent: MosaicoList,
checkPermissions: {
...namespaceCheckPermissions('createMosaicoTemplate')
},
panelRender: props => <MosaicoList permissions={props.permissions}/>,
children: {
':mosaiceTemplateId([0-9]+)': {
title: resolved => t('mosaicoTemplateName', {name: ellipsizeBreadcrumbLabel(resolved.mosaicoTemplate.name)}),
@ -65,7 +83,7 @@ function getMenus(t) {
title: t('edit'),
link: params => `/templates/mosaico/${params.mosaiceTemplateId}/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: {
title: t('files'),
@ -90,7 +108,7 @@ function getMenus(t) {
create: {
title: t('create'),
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 passwordValidator from '../../../shared/password-validator';
import mailtrainConfig from 'mailtrainConfig';
import {NamespaceSelect, validateNamespace} from '../lib/namespace';
import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers";
@ -49,7 +49,8 @@ export default class CUD extends Component {
static propTypes = {
action: PropTypes.string.isRequired,
entity: PropTypes.object
entity: PropTypes.object,
permissions: PropTypes.object
}
getFormValuesMutator(data) {
@ -71,7 +72,8 @@ export default class CUD extends Component {
email: '',
password: '',
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 UserShares from '../shares/UserShares';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
import MosaicoCUD from "../templates/mosaico/CUD";
function getMenus(t) {
return {
'users': {
title: t('users'),
link: '/users',
checkPermissions: {
...namespaceCheckPermissions('manageUsers')
},
panelComponent: List,
children: {
':userId([0-9]+)': {
@ -23,7 +28,7 @@ function getMenus(t) {
':action(edit|delete)': {
title: t('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: {
title: t('shares'),
@ -34,7 +39,7 @@ function getMenus(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 ------------- */
.help-block {
.option-help-block {
display: block;
font-size: .9em;
line-height: 1;
color: #999999;
margin-left: 3px;
margin-bottom: 4px;
}
form a {

View file

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

View file

@ -105,6 +105,7 @@
"save": "Save",
"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",
"copy": "Copiar",
"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",
"testSend": "Test send",
@ -992,8 +993,8 @@
"templateDeleted": "Template deleted",
"editTemplate": "Edit Template",
"createTemplate": "Create 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": "Clonar a partir de templates ya existentes",
"cloneFromAnExistingCustomForms": "Clonar a partir de formularios personalizados ya existentes",
"mosaico": "Mosaico",
"templateContentHtml": "Template content (HTML)",
"mosaicoTemplateDesigner": "Mosaico Template Designer",

View file

@ -105,6 +105,8 @@
"save": "Salvar",
"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",
"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 - 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",
@ -994,6 +996,8 @@
"createTemplate": "Criar modelo",
"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",
"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",
"templateContentHtml": "Conteúdo do modelo (HTML)",
"mosaicoTemplateDesigner": "Editor do Modelo Mosaico",

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

View file

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

View file

@ -36,6 +36,11 @@ editors:
- ckeditor4
- 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
defaultLanguage: en-US
@ -165,6 +170,23 @@ queue:
# How many parallel sender processes to spawn
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:
# Allow subscription widgets to be embedded
# origins: ['https://www.example.com']
@ -235,7 +257,11 @@ seleniumWebDriver:
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:
master:
name: Global Master
@ -260,9 +286,9 @@ roles:
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createCampaign, manageUsers]
children:
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]
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]
report: [view, edit, delete, share, execute, viewContent, viewOutput]
reportTemplate: [view, edit, delete, share, execute]
@ -275,9 +301,9 @@ roles:
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createTemplate, createMosaicoTemplate, createCampaign]
children:
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]
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]
report: [view, edit, delete, share, execute, viewContent, viewOutput]
reportTemplate: [view, share, execute]
@ -286,12 +312,24 @@ roles:
campaignsCreator:
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]
children:
sendConfiguration: [viewPublic]
campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, fetchRss]
template: [view, edit, delete, share, viewFiles, manageFiles]
campaign: [view, edit, delete, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, sendToTestUsers, viewStats, fetchRss]
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:
master:
@ -301,17 +339,17 @@ roles:
campaignsCreator:
name: Campaigns Creator
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:
master:
name: Master
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:
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.
permissions: [view, viewFields, viewSegments]
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, viewTestSubscriptions, sendToTestUsers]
customForm:
master:
@ -323,11 +361,15 @@ roles:
master:
name: Master
description: All permissions
permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, viewStats, manageMessages, fetchRss]
campaignsCreator:
name: Campaigns Creator
permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, sendToTestUsers, viewStats, manageMessages, fetchRss]
creator:
name: Creator
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:
name: RSS Campaign Trigger
description: Allows triggering a fetch of an RSS campaign
@ -338,6 +380,10 @@ roles:
name: Master
description: All permissions
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:
master:
@ -356,5 +402,8 @@ roles:
name: Master
description: All permissions
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';
const config = require('config');
const config = require('./lib/config');
const log = require('./lib/log');
const appBuilder = require('./app-builder');
const translate = require('./lib/translate');

View file

@ -1,20 +1,34 @@
'use strict';
const config = require('config');
const config = require('./config');
const fork = require('./fork').fork;
const log = require('./log');
const path = require('path');
const fs = require('fs-extra')
const fs = require('fs-extra');
const crypto = require('crypto');
const bluebird = require('bluebird');
let zoneMtaProcess;
let zoneMtaProcess = null;
const zoneMtaDir = path.join(__dirname, '..', '..', 'zone-mta');
const zoneMtaBuiltingConfig = path.join(zoneMtaDir, 'config', 'builtin-zonemta.json');
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() {
return 'mailtrain';
}
@ -119,11 +133,14 @@ async function createConfig() {
await fs.writeFile(zoneMtaBuiltingConfig, JSON.stringify(cnf, null, 2));
}
function spawn(callback) {
if (config.builtinZoneMTA.enabled) {
function restart(callback) {
if (zoneMtaProcess) return callback();
createConfig().then(() => {
if (restartCount === 0) {
log.info('ZoneMTA', 'Starting built-in Zone MTA process');
} else {
log.info('ZoneMTA', `Restarting built-in Zone MTA process (restart count ${restartCount})`);
}
zoneMtaProcess = fork(
path.join(zoneMtaDir, 'index.js'),
@ -138,17 +155,36 @@ function spawn(callback) {
if (msg) {
if (msg.type === 'zone-mta-started') {
log.info('ZoneMTA', 'ZoneMTA process started');
if (callback) {
return callback();
} else if (msg.type === 'entries-added') {
senders.scheduleCheck();
} else {
return;
}
}
}
});
zoneMtaProcess.on('close', (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));
} 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';
const passport = require('./passport');
const config = require('config');
const config = require('./config');
const forms = require('../models/forms');
const shares = require('../models/shares');
const urls = require('./urls');
@ -44,6 +44,7 @@ async function getAuthenticatedConfig(context) {
},
globalPermissions,
editors: config.editors,
tagLanguages: config.tagLanguages,
mosaico: config.mosaico,
verpEnabled: config.verp.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
*/
const config = require('config');
const config = require('./config');
const mysql = require('mysql');
const log = require('./log');
const fs = require('fs');
@ -12,7 +12,7 @@ const pathlib = require('path');
const Handlebars = require('handlebars');
const bluebird = require('bluebird');
const highestLegacySchemaVersion = 33;
const highestLegacySchemaVersion = 34;
const mysqlConfig = {
multipleStatements: true

View file

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

View file

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

View file

@ -1,10 +1,14 @@
'use strict';
const crypto = require('crypto');
module.exports = {
enforce,
cleanupFromPost,
filterObject,
castToInteger
castToInteger,
normalizeEmail,
hashEmail
};
function enforce(condition, message) {
@ -28,12 +32,30 @@ function filterObject(obj, allowedKeys) {
return result;
}
function castToInteger(id) {
function castToInteger(id, msg) {
const val = parseInt(id);
if (!Number.isInteger(val)) {
throw new Error('Invalid id');
throw new Error(msg || 'Invalid id');
}
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';
const config = require('config');
const config = require('./config');
const path = require('path');
const knexConstructor = require('knex');
@ -23,6 +23,12 @@ const knex = require('knex')({
//, 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;

View file

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

View file

@ -1,10 +1,8 @@
'use strict';
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 aws = require('aws-sdk');
const openpgpEncrypt = require('nodemailer-openpgp').openpgpEncrypt;
@ -14,13 +12,21 @@ const builtinZoneMta = require('./builtin-zone-mta');
const contextHelpers = require('./context-helpers');
const settings = require('../models/settings');
const tools = require('./tools');
const htmlToText = require('html-to-text');
const bluebird = require('bluebird');
const transports = new Map();
class SendConfigurationError extends Error {
constructor(sendConfigurationId, ...args) {
super(...args);
this.sendConfigurationId = sendConfigurationId;
Error.captureStackTrace(this, SendConfigurationError);
}
}
async function getOrCreateMailer(sendConfigurationId) {
let sendConfiguration;
@ -73,62 +79,26 @@ function _addDkimKeys(transport, mail) {
async function _sendMail(transport, mail, template) {
_addDkimKeys(transport, mail);
let tryCount = 0;
const trySend = (callback) => {
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);
});
};
try {
return await transport.sendMailAsync(mail);
const trySendAsync = bluebird.promisify(trySend);
return await trySendAsync();
} catch (err) {
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) {
const sendConfiguration = transport.mailer.sendConfiguration;
throw err;
}
}
async function _sendTransactionalMail(transport, mail) {
if (!mail.headers) {
mail.headers = {};
}
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);
}
@ -218,6 +188,7 @@ async function _createTransport(sendConfiguration) {
}
const transport = nodemailer.createTransport(transportOptions, config.nodemailer);
transport.sendMailAsync = bluebird.promisify(transport.sendMail.bind(transport));
transport.use('stream', openpgpEncrypt({
signingKey: configItems.pgpPrivateKey,
@ -263,7 +234,7 @@ async function _createTransport(sendConfiguration) {
transport.mailer = {
sendConfiguration,
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)
};
@ -281,3 +252,4 @@ class MailerError extends Error {
module.exports.getOrCreateMailer = getOrCreateMailer;
module.exports.invalidateMailer = invalidateMailer;
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';
const config = require('config');
const config = require('./config');
const log = require('./log');
const util = require('util');

View file

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

View file

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

View file

@ -5,11 +5,11 @@ const fields = require('../models/fields');
const settings = require('../models/settings');
const {getTrustedUrl, getPublicUrl} = require('./urls');
const { tUI, tMark } = require('./translate');
const util = require('util');
const contextHelpers = require('./context-helpers');
const {getFieldColumn} = require('../../shared/lists');
const {getFieldColumn, toNameTagLangauge} = require('../../shared/lists');
const forms = require('../models/forms');
const mailers = require('./mailers');
const messageSender = require('./message-sender');
const tools = require('./tools');
module.exports = {
sendAlreadySubscribed,
@ -65,38 +65,13 @@ async function sendUnsubscriptionConfirmed(locale, list, email, 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) {
const flds = await fields.list(contextHelpers.getAdminContext(), list.id);
subscription = {
...subscription,
email
};
const flds = await fields.listGrouped(contextHelpers.getAdminContext(), list.id);
const encryptionKeys = [];
for (const fld of flds) {
@ -137,21 +112,24 @@ async function _sendMail(list, email, template, locale, subjectKey, relativeUrls
}
try {
const mergeTags = fields.getMergeTags(flds, subscription);
if (list.send_configuration) {
const mailer = await mailers.getOrCreateMailer(list.send_configuration);
await mailer.sendTransactionalMail({
to: {
name: getDisplayName(flds, subscription),
await messageSender.queueSubscriptionMessage(
list.send_configuration,
{
name: list.to_name === null ? undefined : tools.formatTemplate(list.to_name, toNameTagLangauge, mergeTags, false),
address: email
},
subject: tUI(subjectKey, locale, { list: list.name }),
encryptionKeys
}, {
tUI(subjectKey, locale, { list: list.name }),
encryptionKeys,
{
html,
text,
locale,
data
});
}
);
} else {
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';
const util = require('util');
const isemail = require('isemail');
const path = require('path');
const {getPublicUrl} = require('./urls');
const bluebird = require('bluebird');
const {enforce} = require('./helpers');
const hasher = require('node-object-hash')();
const mjml2html = require('mjml');
@ -21,6 +18,8 @@ const fs = require('fs-extra');
const { JSDOM } = require('jsdom');
const { tUI, tLog, getLangCodeFromExpressLocale } = require('./translate');
const {TagLanguages} = require('../../shared/templates');
const templates = new Map();
@ -42,9 +41,7 @@ async function getLocalizedFile(basePath, fileName, language) {
}
async function getTemplate(template, locale) {
if (!template) {
return false;
}
enforce(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) {
let t;
if (language) {
if (language !== undefined) {
t = (key, args) => tUI(key, language, args);
} else {
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);
return formatTemplate(message, links, mergeTags, isHTML);
mergeTags = {...mergeTags, ...links};
return formatTemplate(source, tagLanguage, mergeTags, isHTML);
}
function formatTemplate(template, links, mergeTags, isHTML) {
if (!links && !mergeTags) { return template; }
function _formatTemplateSimple(source, mergeTags, isHTML) {
if (!mergeTags) { return source; }
const getValue = fullKey => {
const keys = (fullKey || '').split('.');
if (links && links.hasOwnProperty(keys[0])) {
return links[keys[0]];
}
if (!mergeTags) { return false; }
let value = mergeTags;
while (keys.length > 0) {
let key = keys.shift();
@ -181,7 +173,7 @@ function formatTemplate(template, links, mergeTags, isHTML) {
}) : (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);
if (value === false) {
return match;
@ -191,11 +183,22 @@ function formatTemplate(template, links, mergeTags, isHTML) {
});
}
async function prepareHtml(html) {
if (!(html || '').toString().trim()) {
return false;
function _formatTemplateHbs(source, mergeTags, isHTML) {
const renderer = hbs.handlebars.compile(source);
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 head = window.document.querySelector('head');
@ -221,14 +224,26 @@ async function prepareHtml(html) {
}
function getMessageLinks(campaign, list, subscription) {
return {
LINK_UNSUBSCRIBE: getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?c=' + campaign.cid),
LINK_PREFERENCES: getPublicUrl('/subscription/' + list.cid + '/manage/' + subscription.cid),
LINK_BROWSER: getPublicUrl('/archive/' + campaign.cid + '/' + list.cid + '/' + subscription.cid),
CAMPAIGN_ID: campaign.cid,
LIST_ID: list.cid,
SUBSCRIPTION_ID: subscription.cid
};
const result = {};
if (list && subscription) {
if (campaign) {
result.LINK_UNSUBSCRIBE = getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscription.cid + '?c=' + campaign.cid);
result.LINK_BROWSER = getPublicUrl('/archive/' + campaign.cid + '/' + list.cid + '/' + 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 = {
@ -237,7 +252,7 @@ module.exports = {
getTemplate,
prepareHtml,
getMessageLinks,
formatMessage,
formatCampaignTemplate,
formatTemplate
};

View file

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

View file

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

View file

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

View file

@ -10,23 +10,26 @@ const shares = require('./shares');
const namespaceHelpers = require('../lib/namespace-helpers');
const files = require('./files');
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 triggers = require('./triggers');
const {SubscriptionStatus} = require('../../shared/lists');
const subscriptions = require('./subscriptions');
const segments = require('./segments');
const senders = require('../lib/senders');
const {LinkId} = require('./links');
const links = require('./links');
const feedcheck = require('../lib/feedcheck');
const contextHelpers = require('../lib/context-helpers');
const {convertFileURLs} = require('../lib/campaign-content');
const messageSender = require('../lib/message-sender');
const lists = require('./lists');
const {EntityActivityType, CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
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 allowedKeysCreateRssEntry = new Set(['type', 'source', 'parent', ...allowedKeysCommon]);
@ -168,7 +171,7 @@ async function listTestUsersDTAjax(context, campaignId, params) {
let subsQry;
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);
} else {
@ -182,7 +185,7 @@ async function listTestUsersDTAjax(context, campaignId, params) {
return await dtHelpers.ajaxListWithPermissionsTx(
tx,
context,
[{ entityTypeId: 'list', requiredOperations: ['viewSubscriptions'], column: 'subs.list_id' }],
[{ entityTypeId: 'list', requiredOperations: ['viewTestSubscriptions'], column: 'subs.list_id' }],
params,
builder => {
return builder.from(function () {
@ -296,7 +299,7 @@ async function listOpensDTAjax(context, campaignId, params) {
return this.from('campaign_links')
.where('campaign_links.campaign', campaignId)
.where('campaign_links.list', cpgList.list)
.where('campaign_links.link', LinkId.OPEN)
.where('campaign_links.link', links.LinkId.OPEN)
.as('related_campaign_links');
},
'related_campaign_links.subscription', subsTable + '.id')
@ -336,13 +339,18 @@ async function getTrackingSettingsByCidTx(tx, cid) {
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) {
const entity = await tx('campaigns').where('campaigns.' + key, id)
.leftJoin('campaign_lists', 'campaigns.id', 'campaign_lists.campaign')
.groupBy('campaigns.id')
.select([
'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.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`)
@ -382,11 +390,13 @@ async function getByIdTx(tx, context, id, withPermissions = true, content = Cont
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
const unsentQryGen = await getSubscribersQueryGeneratorTx(tx, id);
if (unsentQryGen) {
const res = await unsentQryGen(tx).count('* AS subscriptionsToSend').first();
entity.subscriptionsToSend = res.subscriptionsToSend;
}
const totalRes = await tx('campaign_messages')
.where({campaign: id})
.whereIn('status', [CampaignMessageStatus.SCHEDULED, CampaignMessageStatus.SENT,
CampaignMessageStatus.COMPLAINED, CampaignMessageStatus.UNSUBSCRIBED, CampaignMessageStatus.BOUNCED])
.count('* as count').first();
entity.total = totalRes.count;
} else if (content === Content.WITHOUT_SOURCE_CUSTOM) {
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');
}
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) {
@ -460,6 +474,7 @@ async function _createTx(tx, context, entity, content) {
entity.data.sourceCustom = {
type: template.type,
tag_language: template.tag_language,
data: template.data,
html: template.html,
text: template.text
@ -557,7 +572,7 @@ async function updateWithConsistencyCheck(context, entity, content) {
} else if (content === Content.WITHOUT_SOURCE_CUSTOM) {
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) {
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;
if (typeof campaignId === 'object') {
campaign = campaignId;
if (typeof campaignOrCampaignId === 'object') {
campaign = campaignOrCampaignId;
} 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 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);
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaign.id, 'send');
const requiredListAndCampaignPermission = isToTestUsers ? 'sendToTestUsers' : '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('campaigns.cid', campaignCid)
.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();
@ -692,55 +718,45 @@ async function getMessageByCid(messageCid, withVerpHostname = false) { // withVe
}
async function getMessageByResponseId(responseId) {
return await knex.transaction(async tx => {
const message = await tx('campaign_messages')
return await knex('campaign_messages')
.where('campaign_messages.response_id', responseId)
.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();
return message;
});
}
const statusFieldMapping = {
[SubscriptionStatus.UNSUBSCRIBED]: 'unsubscribed',
[SubscriptionStatus.BOUNCED]: 'bounced',
[SubscriptionStatus.COMPLAINED]: 'complained'
};
const statusFieldMapping = new Map();
statusFieldMapping.set(CampaignMessageStatus.UNSUBSCRIBED, 'unsubscribed');
statusFieldMapping.set(CampaignMessageStatus.BOUNCED, 'bounced');
statusFieldMapping.set(CampaignMessageStatus.COMPLAINED, 'complained');
async function _changeStatusByMessageTx(tx, context, message, subscriptionStatus) {
enforce(subscriptionStatus !== SubscriptionStatus.SUBSCRIBED);
async function _changeStatusByMessageTx(tx, context, message, campaignMessageStatus) {
enforce(statusFieldMapping.has(campaignMessageStatus));
if (message.status === SubscriptionStatus.SUBSCRIBED) {
if (message.status === SubscriptionStatus.SENT) {
await shares.enforceEntityPermissionTx(tx, context, 'campaign', message.campaign, 'manageMessages');
if (!subscriptionStatus in statusFieldMapping) {
throw new Error('Unrecognized message status');
}
const statusField = statusFieldMapping[subscriptionStatus];
const statusField = statusFieldMapping.get(campaignMessageStatus);
await tx('campaigns').increment(statusField, 1).where('id', message.campaign);
await tx('campaign_messages')
.where('id', message.id)
.update({
status: subscriptionStatus,
status: campaignMessageStatus,
updated: knex.fn.now()
});
}
}
async function changeStatusByCampaignCidAndSubscriptionIdTx(tx, context, campaignCid, listId, subscriptionId, subscriptionStatus) {
const campaign = await tx('campaigns').where('cid', campaignCid);
async function changeStatusByCampaignCidAndSubscriptionIdTx(tx, context, campaignCid, listId, subscriptionId, campaignMessageStatus) {
const message = await tx('campaign_messages')
.innerJoin('campaigns', 'campaign_messages.campaign', 'campaigns.id')
.where('campaigns.cid', campaignCid)
.where({subscription: subscriptionId, list: listId})
.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();
@ -748,17 +764,23 @@ async function changeStatusByCampaignCidAndSubscriptionIdTx(tx, context, campaig
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 => {
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) {
/*
This is supposed to produce queries like this:
async function prepareCampaignMessages(campaignId) {
const campaign = await getById(contextHelpers.getAdminContext(), campaignId, false);
select ... from `campaign_lists` inner join (
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'
await knex('campaign_messages').where({campaign: campaignId, status: CampaignMessageStatus.SCHEDULED}).del();
This was too much for Knex, so we partially construct these queries directly as strings;
*/
const subsQrys = [];
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) : () => {};
for (const cpgList of campaign.lists) {
let addSegmentQuery;
await knex.transaction(async tx => {
addSegmentQuery = cpgList.segment ? await segments.getQueryGeneratorTx(tx, cpgList.list, cpgList.segment) : () => {};
});
const subsTable = subscriptions.getSubscriptionTableName(cpgList.list);
const sqlQry = 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')
const subsQry = knex.from(subsTable)
.where(subsTable + '.status', SubscriptionStatus.SUBSCRIBED)
.where(function() {
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();
subsQrys.push(sqlQry);
}
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;
await knex.raw('INSERT IGNORE INTO `campaign_messages` (`hash_email`, `subscription`, `campaign`, `list`, `send_configuration`, `status`) ' + subsQry.sql, subsQry.bindings);
}
}
async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, scheduled = null) {
async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, extraData) {
await knex.transaction(async tx => {
const entity = await tx('campaigns').where('id', campaignId).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
// This is quite inefficient because it selects the same row 3 times. However as status is changed
// rather infrequently, we keep it this way for simplicity
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)) {
throw new interoperableErrors.InvalidStateError(invalidStateMessage);
}
await tx('campaigns').where('id', campaignId).update({
status: newState,
scheduled
if (Array.isArray(newState)) {
const newStateIdx = permittedCurrentStates.indexOf(entity.status);
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});
});
@ -868,22 +882,23 @@ async function _changeStatus(context, campaignId, permittedCurrentStates, newSta
}
async function start(context, campaignId, startAt) {
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);
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, PAUSED, or FINISHED state', extraData);
}
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) {
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');
const entity = await tx('campaigns').where('id', campaignId).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
if (entity.status !== CampaignStatus.FINISHED && entity.status !== CampaignStatus.PAUSED) {
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 => {
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 countries = await tx('campaign_links').where('campaign', id).where('link', LinkId.OPEN).groupBy('country').select('country 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', links.LinkId.OPEN).groupBy('country').select('country AS key').count('* as count');
return {
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.hash = hash;
@ -974,7 +1088,7 @@ module.exports.changeStatusByCampaignCidAndSubscriptionIdTx = changeStatusByCamp
module.exports.changeStatusByMessage = changeStatusByMessage;
module.exports.updateMessageResponse = updateMessageResponse;
module.exports.getSubscribersQueryGeneratorTx = getSubscribersQueryGeneratorTx;
module.exports.prepareCampaignMessages = prepareCampaignMessages;
module.exports.start = start;
module.exports.stop = stop;
@ -987,3 +1101,5 @@ module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
module.exports.getStatisticsOpened = getStatisticsOpened;
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) {
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) {
return false;

View file

@ -2,7 +2,6 @@
const knex = require('../lib/knex');
const hasher = require('node-object-hash')();
const slugify = require('slugify');
const { enforce, filterObject } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../../shared/interoperable-errors');
@ -20,8 +19,8 @@ const {ListActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const allowedKeysCreate = new Set(['name', 'key', 'default_value', 'type', 'group', 'settings']);
const allowedKeysUpdate = new Set(['name', 'key', 'default_value', 'group', 'settings']);
const allowedKeysCreate = new Set(['name', 'help', 'key', 'default_value', 'type', 'group', 'settings']);
const allowedKeysUpdate = new Set(['name', 'help', 'key', 'default_value', 'group', 'settings']);
const hashKeys = allowedKeysCreate;
const fieldTypes = {};
@ -304,7 +303,7 @@ async function getById(context, listId, id) {
}
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) {
@ -543,7 +542,7 @@ async function createTx(tx, context, listId, entity) {
let columnName;
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);
@ -661,10 +660,11 @@ function forHbsWithFieldsGrouped(fieldsGrouped, subscription) { // assumes group
const entry = {
name: fld.name,
key: fld.key,
help: fld.help,
field: fld,
[type.getHbsType(fld)]: true,
order_subscribe: fld.order_subscribe,
order_manage: fld.order_manage,
order_manage: fld.order_manage
};
if (!type.grouped && !type.enumerated) {
@ -688,6 +688,7 @@ function forHbsWithFieldsGrouped(fieldsGrouped, subscription) { // assumes group
options.push({
key: opt.key,
name: opt.name,
help: opt.help,
value: isEnabled
});
}
@ -702,6 +703,7 @@ function forHbsWithFieldsGrouped(fieldsGrouped, subscription) { // assumes group
options.push({
key: opt.key,
name: opt.label,
help: opt.help,
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'));
return await dtHelpers.ajaxList(
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']
);
}
@ -53,7 +53,7 @@ async function listDTAjax(context, type, subType, entityId, params) {
async function listTx(tx, context, type, subType, entityId) {
enforceTypePermitted(type, subType);
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) {
@ -65,7 +65,7 @@ async function list(context, type, subType, entityId) {
async function getFileById(context, type, subType, id) {
enforceTypePermitted(type, subType);
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'));
return file;
});
@ -85,7 +85,7 @@ async function _getFileBy(context, type, subType, entityId, key, value) {
enforceTypePermitted(type, subType);
const file = await knex.transaction(async tx => {
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;
});
@ -155,7 +155,7 @@ async function createFiles(context, type, subType, entityId, files, replacementB
await knex.transaction(async tx => {
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();
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);
const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type, subType)).where('id', id).select('entity', 'filename').first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'manage'));
await tx(getFilesTable(type, subType)).where('id', id).del();
return {filename: file.filename, entity: file.entity};
});
const filesTableName = getFilesTable(type, subType);
const file = await tx(filesTableName).where('id', id).first();
enforce(file, `File ${id} not found`);
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);
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) {
@ -296,7 +327,7 @@ async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toTyp
enforceTypePermitted(toType, toSubType);
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) {
const fromFilePath = getFilePath(fromType, fromSubType, fromEntityId, row.filename);
const toFilePath = getFilePath(toType, toSubType, toEntityId, row.filename);
@ -339,4 +370,6 @@ module.exports.getFileUrl = getFileUrl;
module.exports.getFilePath = getFilePath;
module.exports.copyAllTx = copyAllTx;
module.exports.removeAllTx = removeAllTx;
module.exports.lockTx = lockTx;
module.exports.unlockTx = unlockTx;
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();
if (!entity) {
@ -86,17 +86,20 @@ async function _getById(tx, id) {
return entity;
}
async function getById(context, id, withPermissions = true) {
return await knex.transaction(async tx => {
async function getByIdTx(tx, context, id, withPermissions = true) {
await shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view');
const entity = await _getById(tx, id);
const entity = await _getByIdTx(tx, id);
if (withPermissions) {
entity.permissions = await shares.getPermissionsTx(tx, context, 'customForm', id);
}
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 => {
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);
const form = filterObject(entity, allowedFormKeys);
@ -147,7 +161,7 @@ async function updateWithConsistencyCheck(context, entity) {
await knex.transaction(async tx => {
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);
if (existingHash !== entity.originalHash) {
@ -219,7 +233,7 @@ async function getDefaultCustomFormValues() {
// TODO - this could run in the browser too - move to shared
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 source = layout.replace(/\{\{\{body\}\}\}/g, template);
@ -283,6 +297,7 @@ function checkForMjmlErrors(form) {
module.exports.listDTAjax = listDTAjax;
module.exports.hash = hash;
module.exports.getById = getById;
module.exports.getByIdTx = getByIdTx;
module.exports.create = create;
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
module.exports.remove = remove;

View file

@ -11,6 +11,7 @@ const he = require('he');
const { getPublicUrl } = require('../lib/urls');
const tools = require('../lib/tools');
const shortid = require('shortid');
const {enforce} = require('../lib/helpers');
const LinkId = {
OPEN: -1,
@ -103,8 +104,7 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
}
async function addOrGet(campaignId, url) {
return await knex.transaction(async tx => {
const link = await tx('links').select(['id', 'cid']).where({
const link = await knex('links').select(['id', 'cid']).where({
campaign: campaignId,
url
}).first();
@ -112,7 +112,8 @@ async function addOrGet(campaignId, url) {
if (!link) {
let cid = shortid.generate();
const ids = await tx('links').insert({
try {
const ids = await knex('links').insert({
campaign: campaignId,
cid,
url
@ -122,16 +123,27 @@ async function addOrGet(campaignId, url) {
id: ids[0],
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 {
return link;
}
});
}
async function updateLinks(campaign, list, subscription, mergeTags, message) {
if ((campaign.open_tracking_disabled && campaign.click_tracking_disabled) || !message || !message.trim()) {
async function updateLinks(source, tagLanguage, mergeTags, campaign, list, subscription) {
if ((campaign.open_tracking_disabled && campaign.click_tracking_disabled) || !source || !source.trim()) {
// tracking is disabled, do not modify the message
return message;
return source;
}
// insert tracking image
@ -139,12 +151,12 @@ async function updateLinks(campaign, list, subscription, mergeTags, message) {
let inserted = false;
const imgUrl = getPublicUrl(`/links/${campaign.cid}/${list.cid}/${subscription.cid}`);
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;
return img + match;
});
if (!inserted) {
message = message + img;
source = source + img;
}
}
@ -153,7 +165,7 @@ async function updateLinks(campaign, list, subscription, mergeTags, message) {
const urlsToBeReplaced = new Set();
message.replace(re, (match, prefix, encodedUrl) => {
source.replace(re, (match, prefix, encodedUrl) => {
const url = he.decode(encodedUrl, {isAttributeValue: true});
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)
for (const url of urlsToBeReplaced) {
// 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);
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 link = urls.get(url);
return prefix + (link ? getPublicUrl(`/links/${campaign.cid}/${list.cid}/${subscription.cid}/${link.cid}`) : url);
});
}
return message;
return source;
}
module.exports.LinkId = LinkId;

View file

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

View file

@ -226,7 +226,16 @@ async function remove(context, id) {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', id, 'delete');
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();
});

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