Merge branch 'transactional-mail-v2' into development

This commit is contained in:
Alexey Zinkevych 2019-03-31 11:58:15 +03:00
commit 31442453ea
153 changed files with 10217 additions and 9007 deletions

2
.gitmodules vendored
View file

@ -1,3 +1,3 @@
[submodule "mvis/ivis-core"] [submodule "mvis/ivis-core"]
path = mvis/ivis-core path = mvis/ivis-core
url = git@gitlab.d3s.mff.cuni.cz:evif/ivis-core.git url = https://gitlab.d3s.mff.cuni.cz/evif/ivis-core.git

View file

@ -1,6 +1,6 @@
# Mailtrain v2 (beta) # Mailtrain v2 (beta)
[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v10+) and MySQL (v8+) or MariaDB (v10+). Mailtrain is a self hosted newsletter application built on Node.js (v10+) and MySQL (v8+) or MariaDB (v10+).
![](https://mailtrain.org/mailtrain.png) ![](https://mailtrain.org/mailtrain.png)
@ -176,18 +176,15 @@ To deploy Mailtrain with Docker, you need the following three dependencies insta
- [Docker](https://www.docker.com/) - [Docker](https://www.docker.com/)
- [Docker Compose](https://docs.docker.com/compose/) - [Docker Compose](https://docs.docker.com/compose/)
- Git - Typically already present. If not, just use the package manager of your OS distribution to install it.
These are the steps to start Mailtrain via docker-compose: These are the steps to start Mailtrain via docker-compose:
1. Download Mailtrain using git 1. Download Mailtrain's docker-compose build file
``` ```
git clone https://github.com/Mailtrain-org/mailtrain.git curl -O https://raw.githubusercontent.com/Mailtrain-org/mailtrain/development/docker-compose.yml
cd mailtrain
git checkout development
``` ```
2. Deploy Mailtrain via docker-compose (in the root directory of the Mailtrain project). This will take quite some time when run for the first time. Subsequent executions will be fast. 2. Deploy Mailtrain via docker-compose (in the directory to which you downloaded the `docker-compose.yml` file). This will take quite some time when run for the first time. Subsequent executions will be fast.
``` ```
docker-compose up docker-compose up
``` ```
@ -201,6 +198,8 @@ These are the steps to start Mailtrain via docker-compose:
4. Authenticate as `admin`:`test` 4. Authenticate as `admin`:`test`
The instructions above use an automatically built Docker image on DockerHub (https://hub.docker.com/r/mailtrain/mailtrain). If you want to build the Docker image yourself (e.g. when doing development), use the `docker-compose-local-build.yml` located in the project's root directory.
## License ## License

1513
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -26,14 +26,15 @@
"bootstrap": "^4.2.1", "bootstrap": "^4.2.1",
"datatables.net": "^1.10.19", "datatables.net": "^1.10.19",
"datatables.net-bs4": "^1.10.19", "datatables.net-bs4": "^1.10.19",
"ellipsize": "^0.1.0",
"grapesjs": "^0.14.49", "grapesjs": "^0.14.49",
"grapesjs-mjml": "0.0.27", "grapesjs-mjml": "0.0.31",
"grapesjs-preset-newsletter": "^0.2.20", "grapesjs-preset-newsletter": "^0.2.20",
"i18next": "^13.1.0", "i18next": "^13.1.0",
"i18next-browser-languagedetector": "^2.2.4", "i18next-browser-languagedetector": "^2.2.4",
"immutable": "^4.0.0-rc.12", "immutable": "^4.0.0-rc.12",
"juice": "^5.1.0", "juice": "^5.1.0",
"mjml4-in-browser": "^1.0.2", "mjml4-in-browser": "^1.1.0",
"moment": "^2.23.0", "moment": "^2.23.0",
"moment-timezone": "^0.5.23", "moment-timezone": "^0.5.23",
"popper.js": "^1.14.6", "popper.js": "^1.14.6",

View file

@ -164,9 +164,7 @@ export default class CUD extends Component {
} }
} }
componentDidMount() { getFormValuesMutator(data) {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
// The source cannot be changed once campaign is created. Thus we don't have to initialize fields for all other sources // The source cannot be changed once campaign is created. Thus we don't have to initialize fields for all other sources
if (data.source === CampaignSource.TEMPLATE) { if (data.source === CampaignSource.TEMPLATE) {
data.data_sourceTemplate = data.data.sourceTemplate; data.data_sourceTemplate = data.data.sourceTemplate;
@ -200,7 +198,11 @@ export default class CUD extends Component {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
this.fetchSendConfiguration(data.send_configuration); this.fetchSendConfiguration(data.send_configuration);
}); }
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
if (this.props.entity.status === CampaignStatus.SENDING) { if (this.props.entity.status === CampaignStatus.SENDING) {
this.disableForm(); this.disableForm();
@ -337,7 +339,13 @@ export default class CUD extends Component {
validateNamespace(t, state); validateNamespace(t, state);
} }
async submitHandler() { static AfterSubmitAction = {
STAY: 0,
LEAVE: 1,
STATUS: 2
}
async submitHandler(afterSubmitAction) {
const isEdit = !!this.props.entity; const isEdit = !!this.props.entity;
const t = this.props.t; const t = this.props.t;
@ -353,7 +361,7 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
data.source = Number.parseInt(data.source); data.source = Number.parseInt(data.source);
data.data = {}; data.data = {};
@ -411,14 +419,31 @@ export default class CUD extends Component {
} }
}); });
if (submitResponse) { if (submitResult) {
const sourceTypeKey = Number.parseInt(this.getFormValue('source'));
if (this.props.entity) { if (this.props.entity) {
this.navigateToWithFlashMessage('/campaigns', 'success', t('campaignSaved')); if (afterSubmitAction === CUD.AfterSubmitAction.STATUS) {
} else if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) { this.navigateToWithFlashMessage(`/campaigns/${this.props.entity.id}/status`, 'success', t('Campaign updated'));
this.navigateToWithFlashMessage(`/campaigns/${submitResponse}/content`, 'success', t('campaignSaved')); } else if (afterSubmitAction === CUD.AfterSubmitAction.LEAVE) {
this.navigateToWithFlashMessage('/campaigns', 'success', t('Campaign updated'));
} else { } else {
this.navigateToWithFlashMessage(`/campaigns/${submitResponse}/status`, 'success', t('campaignSaved')); await this.getFormValuesFromURL(`rest/campaigns-settings/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Campaign updated'));
}
} else {
const sourceTypeKey = Number.parseInt(this.getFormValue('source'));
if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/content`, 'success', t('Campaign created'));
} else {
if (afterSubmitAction === CUD.AfterSubmitAction.STATUS) {
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/status`, 'success', t('Campaign created'));
} else if (afterSubmitAction === CUD.AfterSubmitAction.LEAVE) {
this.navigateToWithFlashMessage(`/campaigns`, 'success', t('Campaign created'));
} else {
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/edit`, 'success', t('Campaign created'));
}
}
} }
} else { } else {
this.enableForm(); this.enableForm();
@ -583,13 +608,21 @@ export default class CUD extends Component {
sendSettings = []; sendSettings = [];
const addOverridable = (id, label) => { const addOverridable = (id, label) => {
sendSettings.push(<CheckBox key={id + '_overriden'} id={id + '_overriden'} label={label} text={t('override')}/>); if(this.state.sendConfiguration[id + '_overridable']){
if (this.getFormValue(id + '_overriden')) { if (this.getFormValue(id + '_overriden')) {
sendSettings.push(<InputField key={id + '_override'} id={id + '_override'}/>); sendSettings.push(<InputField label={t(label)} key={id + '_override'} id={id + '_override'}/>);
} else { } else {
sendSettings.push( sendSettings.push(
<StaticField key={id + '_original'} id={id + '_original'} className={styles.formDisabled}> <StaticField key={id + '_original'} label={t(label)} id={id + '_original'} className={styles.formDisabled}>
{this.state.sendConfiguration[id]}
</StaticField>
);
}
sendSettings.push(<CheckBox key={id + '_overriden'} id={id + '_overriden'} text={t('override')} className={campaignsStyles.overrideCheckbox}/>);
}
else{
sendSettings.push(
<StaticField key={id + '_original'} label={t(label)} id={id + '_original'} className={styles.formDisabled}>
{this.state.sendConfiguration[id]} {this.state.sendConfiguration[id]}
</StaticField> </StaticField>
); );
@ -666,17 +699,6 @@ export default class CUD extends Component {
templateEdit = <InputField id="data_sourceUrl" label={t('renderUrl')} help={t('ifAMessageIsSentThenThisUrlWillBePosTed')}/> templateEdit = <InputField id="data_sourceUrl" label={t('renderUrl')} help={t('ifAMessageIsSentThenThisUrlWillBePosTed')}/>
} }
let saveButtonLabel;
if (isEdit) {
saveButtonLabel = t('save');
} else if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
saveButtonLabel = t('saveAndEditContent');
} else {
saveButtonLabel = t('saveCampaignAndGoToStatus');
}
return ( return (
<div> <div>
{canDelete && {canDelete &&
@ -719,25 +741,45 @@ export default class CUD extends Component {
<hr/> <hr/>
<Fieldset label={t('sendSettings')}>
<TableSelect id="send_configuration" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} /> <TableSelect id="send_configuration" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
{sendSettings} {sendSettings}
<InputField id="unsubscribe_url" label={t('customUnsubscribeUrl')}/> <InputField id="unsubscribe_url" label={t('customUnsubscribeUrl')}/>
</Fieldset>
<hr/> <hr/>
<Fieldset label={t('Tracking')}>
<CheckBox id="open_tracking_disabled" text={t('disableOpenedTracking')}/> <CheckBox id="open_tracking_disabled" text={t('disableOpenedTracking')}/>
<CheckBox id="click_tracking_disabled" text={t('disableClickedTracking')}/> <CheckBox id="click_tracking_disabled" text={t('disableClickedTracking')}/>
</Fieldset>
{sourceEdit && <hr/> } {sourceEdit &&
<>
<hr/>
<Fieldset label={t('template')}>
{sourceEdit} {sourceEdit}
</Fieldset>
</>
}
{templateEdit} {templateEdit}
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={saveButtonLabel}/> {!isEdit && (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) ?
<Button type="submit" className="btn-primary" icon="check" label={t('Save and edit content')}/>
:
<>
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(CUD.AfterSubmitAction.LEAVE)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and go to status')} onClickAsync={async () => this.submitHandler(CUD.AfterSubmitAction.STATUS)}/>
</>
}
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/campaigns/${this.props.entity.id}/delete`}/> } {canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/campaigns/${this.props.entity.id}/delete`}/> }
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -67,10 +67,12 @@ export default class CustomContent extends Component {
} }
static propTypes = { static propTypes = {
entity: PropTypes.object entity: PropTypes.object,
setPanelInFullScreen: PropTypes.func
} }
loadFromEntityMutator(data) {
getFormValuesMutator(data) {
data.data_sourceCustom_type = data.data.sourceCustom.type; data.data_sourceCustom_type = data.data.sourceCustom.type;
data.data_sourceCustom_data = data.data.sourceCustom.data; data.data_sourceCustom_data = data.data.sourceCustom.data;
data.data_sourceCustom_html = data.data.sourceCustom.html; data.data_sourceCustom_html = data.data.sourceCustom.html;
@ -79,8 +81,9 @@ export default class CustomContent extends Component {
this.templateTypes[data.data.sourceCustom.type].afterLoad(data); this.templateTypes[data.data.sourceCustom.type].afterLoad(data);
} }
componentDidMount() { componentDidMount() {
this.getFormValuesFromEntity(this.props.entity, data => this.loadFromEntityMutator(data)); this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} }
localValidateFormValues(state) { localValidateFormValues(state) {
@ -94,14 +97,16 @@ export default class CustomContent extends Component {
} }
async save() { async save() {
await this.doSave(true); await this.submitHandler(CustomContent.AfterSubmitAction.STAY);
} }
async submitHandler() { static AfterSubmitAction = {
await this.doSave(false); STAY: 0,
LEAVE: 1,
STATUS: 2
} }
async doSave(stayOnPage) { async submitHandler(afterSubmitAction) {
const t = this.props.t; const t = this.props.t;
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type'); const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
@ -113,7 +118,7 @@ export default class CustomContent extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
Object.assign(data, exportedData); Object.assign(data, exportedData);
this.templateTypes[data.data_sourceCustom_type].beforeSave(data); this.templateTypes[data.data_sourceCustom_type].beforeSave(data);
@ -131,15 +136,15 @@ export default class CustomContent extends Component {
} }
}); });
if (submitResponse) { if (submitResult) {
if (stayOnPage) { if (afterSubmitAction === CustomContent.AfterSubmitAction.STATUS) {
await this.getFormValuesFromURL(`rest/campaigns-content/${this.props.entity.id}`, data => this.loadFromEntityMutator(data)); this.navigateToWithFlashMessage(`/campaigns/${this.props.entity.id}/status`, 'success', t('Campaign updated'));
this.enableForm(); } else if (afterSubmitAction === CustomContent.AfterSubmitAction.LEAVE) {
this.clearFormStatusMessage(); this.navigateToWithFlashMessage('/campaigns', 'success', t('Campaign updated'));
this.setFlashMessage('success', t('campaignSaved'));
} else { } else {
this.navigateToWithFlashMessage('/campaigns', 'success', t('campaignSaved')); await this.getFormValuesFromURL(`rest/campaigns-content/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Campaign updated'));
} }
} else { } else {
this.enableForm(); this.enableForm();
@ -177,6 +182,7 @@ export default class CustomContent extends Component {
} }
async setElementInFullscreen(elementInFullscreen) { async setElementInFullscreen(elementInFullscreen) {
this.props.setPanelInFullScreen(elementInFullscreen);
this.setState({ this.setState({
elementInFullscreen elementInFullscreen
}); });
@ -228,8 +234,10 @@ export default class CustomContent extends Component {
{customTemplateTypeKey && getEditForm(this, customTemplateTypeKey, 'data_sourceCustom_')} {customTemplateTypeKey && getEditForm(this, customTemplateTypeKey, 'data_sourceCustom_')}
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button className="btn-danger" icon="send" label={t('testSend')} onClickAsync={async () => this.setState({showTestSendModal: true})}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(CustomContent.AfterSubmitAction.LEAVE)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and go to status')} onClickAsync={async () => this.submitHandler(CustomContent.AfterSubmitAction.STATUS)}/>
<Button className="btn-success" icon="at" label={t('testSend')} onClickAsync={async () => this.setState({showTestSendModal: true})}/>
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -225,6 +225,18 @@ class SendControls extends Component {
await this.refreshEntity(); await this.refreshEntity();
} }
async confirmStart() {
const t = this.props.t;
this.actionDialog(
t('confirmLaunch'),
t('doYouWantToLaunchTheCampaign?All'),
async () => {
await this.startAsync();
await this.refreshEntity();
}
);
}
async resetAsync() { async resetAsync() {
const t = this.props.t; const t = this.props.t;
this.actionDialog( this.actionDialog(
@ -306,7 +318,7 @@ class SendControls extends Component {
{this.getFormValue('sendLater') ? {this.getFormValue('sendLater') ?
<Button className="btn-primary" icon="send" label={(entity.scheduled ? t('rescheduleSend') : t('scheduleSend')) + subscrInfo} onClickAsync={::this.scheduleAsync}/> <Button className="btn-primary" icon="send" label={(entity.scheduled ? t('rescheduleSend') : t('scheduleSend')) + subscrInfo} onClickAsync={::this.scheduleAsync}/>
: :
<Button className="btn-primary" icon="send" label={t('send') + subscrInfo} onClickAsync={::this.startAsync}/> <Button className="btn-primary" icon="send" label={t('send') + subscrInfo} onClickAsync={::this.confirmStart}/>
} }
{entity.status === CampaignStatus.PAUSED && <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>} {entity.status === CampaignStatus.PAUSED && <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>}
</ButtonRow> </ButtonRow>
@ -335,7 +347,7 @@ class SendControls extends Component {
{t('allMessagesSent!HitContinueIfYouYouWant')} {t('allMessagesSent!HitContinueIfYouYouWant')}
</AlignedRow> </AlignedRow>
<ButtonRow> <ButtonRow>
<Button className="btn-primary" icon="play" label={t('continue') + subscrInfo} onClickAsync={::this.startAsync}/> <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="refresh" label={t('reset')} onClickAsync={::this.resetAsync}/>
<LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/> <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>
</ButtonRow> </ButtonRow>
@ -445,9 +457,15 @@ export default class Status extends Component {
sendSettings = []; sendSettings = [];
const addOverridable = (id, label) => { const addOverridable = (id, label) => {
sendSettings.push(<AlignedRow key={id} label={label}>{entity[id + '_override'] === null ? this.state.sendConfiguration[id] : entity[id + '_override']}</AlignedRow>); if(this.state.sendConfiguration[id + '_overridable'] == 1 && entity[id + '_override'] != null){
sendSettings.push(<AlignedRow key={id} label={label}>{entity[id + '_override']}</AlignedRow>);
}
else{
sendSettings.push(<AlignedRow key={id} label={label}>{this.state.sendConfiguration[id]}</AlignedRow>);
}
}; };
addOverridable('from_name', t('fromName')); addOverridable('from_name', t('fromName'));
addOverridable('from_email', t('fromEmailAddress')); addOverridable('from_email', t('fromEmailAddress'));
addOverridable('reply_to', t('replytoEmailAddress')); addOverridable('reply_to', t('replytoEmailAddress'));

View file

@ -115,8 +115,8 @@ export class TestSendModalDialog extends Component {
return ( return (
<ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[ <ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('send'), className: 'btn-danger', onClickAsync: ::this.performAction }, { label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
{ label: t('cancel'), className: 'btn-primary', onClickAsync: ::this.hideModal } { label: t('cancel'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}> ]}>
<Form stateOwner={this} format="wide"> <Form stateOwner={this} format="wide">
<TableSelect id="testUser" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.entity.id}`} columns={testUsersColumns} selectionLabelIndex={1} /> <TableSelect id="testUser" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.entity.id}`} columns={testUsersColumns} selectionLabelIndex={1} />

View file

@ -33,7 +33,8 @@ import StatisticsOpened
from "./StatisticsOpened"; from "./StatisticsOpened";
import StatisticsLinkClicks import StatisticsLinkClicks
from "./StatisticsLinkClicks"; from "./StatisticsLinkClicks";
import TemplatesCUD from "../templates/root";
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"
function getMenus(t) { function getMenus(t) {
const aggLabels = { const aggLabels = {
@ -48,7 +49,7 @@ function getMenus(t) {
panelComponent: CampaignsList, panelComponent: CampaignsList,
children: { children: {
':campaignId([0-9]+)': { ':campaignId([0-9]+)': {
title: resolved => t('campaignName', {name: resolved.campaign.name}), title: resolved => t('campaignName', {name: ellipsizeBreadcrumbLabel(resolved.campaign.name)}),
resolve: { resolve: {
campaign: params => `rest/campaigns-settings/${params.campaignId}` campaign: params => `rest/campaigns-settings/${params.campaignId}`
}, },
@ -120,7 +121,7 @@ function getMenus(t) {
campaignContent: params => `rest/campaigns-content/${params.campaignId}` campaignContent: params => `rest/campaigns-content/${params.campaignId}`
}, },
visible: resolved => resolved.campaign.permissions.includes('edit') && (resolved.campaign.source === CampaignSource.CUSTOM || resolved.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || resolved.campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN), visible: resolved => resolved.campaign.permissions.includes('edit') && (resolved.campaign.source === CampaignSource.CUSTOM || resolved.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || resolved.campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN),
panelRender: props => <Content entity={props.resolved.campaignContent} /> panelRender: props => <Content entity={props.resolved.campaignContent} setPanelInFullScreen={props.setPanelInFullScreen} />
}, },
files: { files: {
title: t('files'), title: t('files'),
@ -141,7 +142,7 @@ function getMenus(t) {
panelRender: props => <TriggersList campaign={props.resolved.campaign} />, panelRender: props => <TriggersList campaign={props.resolved.campaign} />,
children: { children: {
':triggerId([0-9]+)': { ':triggerId([0-9]+)': {
title: resolved => t('triggerName', {name: resolved.trigger.name}), title: resolved => t('triggerName', {name: ellipsizeBreadcrumbLabel(resolved.trigger.name)}),
resolve: { resolve: {
trigger: params => `rest/triggers/${params.campaignId}/${params.triggerId}`, trigger: params => `rest/triggers/${params.campaignId}/${params.triggerId}`,
}, },

View file

@ -89,4 +89,6 @@
margin-bottom: 30px; margin-bottom: 30px;
} }
.overrideCheckbox{
margin-top: -8px !important;
}

View file

@ -86,9 +86,7 @@ export default class CUD extends Component {
entity: PropTypes.object entity: PropTypes.object
} }
componentDidMount() { getFormValuesMutator(data) {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
data.daysAfter = (Math.round(data.seconds / (3600 * 24))).toString(); data.daysAfter = (Math.round(data.seconds / (3600 * 24))).toString();
if (data.entity === Entity.SUBSCRIPTION) { if (data.entity === Entity.SUBSCRIPTION) {
@ -102,7 +100,11 @@ export default class CUD extends Component {
} else { } else {
data.campaignEvent = Event[Entity.CAMPAIGN].DELIVERED; data.campaignEvent = Event[Entity.CAMPAIGN].DELIVERED;
} }
}); }
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} else { } else {
this.populateFormValues({ this.populateFormValues({
@ -145,7 +147,7 @@ export default class CUD extends Component {
} }
} }
async submitHandler() { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -161,7 +163,7 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
data.seconds = Number.parseInt(data.daysAfter) * 3600 * 24; data.seconds = Number.parseInt(data.daysAfter) * 3600 * 24;
if (data.entity === Entity.SUBSCRIPTION) { if (data.entity === Entity.SUBSCRIPTION) {
@ -171,8 +173,22 @@ export default class CUD extends Component {
} }
}); });
if (submitSuccessful) { if (submitResult) {
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers`, 'success', t('triggerSaved')); if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers`, 'success', t('Trigger updated'));
} else {
await this.getFormValuesFromURL(`rest/triggers/${this.props.campaign.id}/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Trigger updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers`, 'success', t('Trigger created'));
} else {
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers/${submitResult}/edit`, 'success', t('Trigger created'));
}
}
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd')); this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
@ -235,7 +251,8 @@ export default class CUD extends Component {
<CheckBox id="enabled" text={t('enabled')}/> <CheckBox id="enabled" text={t('enabled')}/>
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/campaigns/${this.props.campaign.id}/triggers/${this.props.entity.id}/delete`}/>} {isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/campaigns/${this.props.campaign.id}/triggers/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -74,7 +74,8 @@ export class Button extends Component {
iconTitle: PropTypes.string, iconTitle: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
title: PropTypes.string, title: PropTypes.string,
type: PropTypes.string type: PropTypes.string,
disabled: PropTypes.bool
} }
@withAsyncErrorHandler @withAsyncErrorHandler
@ -106,7 +107,7 @@ export class Button extends Component {
} }
return ( return (
<button type={type} className={className} onClick={::this.onClick} title={this.props.title}>{icon}{iconSpacer}{props.label}</button> <button type={type} className={className} onClick={::this.onClick} title={this.props.title} disabled={this.props.disabled}>{icon}{iconSpacer}{props.label}</button>
); );
} }
} }
@ -301,7 +302,7 @@ export class ModalDialog extends Component {
const buttons = []; const buttons = [];
for (let idx = 0; idx < this.state.buttons.length; idx++) { for (let idx = 0; idx < this.state.buttons.length; idx++) {
const buttonSpec = this.state.buttons[idx]; const buttonSpec = this.state.buttons[idx];
const button = <Button key={idx} label={buttonSpec.label} className={buttonSpec.className} onClickAsync={() => this.onButtonClick(idx)} /> const button = <Button key={idx} label={buttonSpec.label} className={buttonSpec.className} onClickAsync={async () => this.onButtonClick(idx)} />
buttons.push(button); buttons.push(button);
} }

View file

@ -100,7 +100,7 @@ class Form extends Component {
evt.preventDefault(); evt.preventDefault();
if (this.props.onSubmitAsync) { if (this.props.onSubmitAsync) {
await owner.formHandleChangedError(async () => await this.props.onSubmitAsync(evt)); await owner.formHandleChangedError(async () => await this.props.onSubmitAsync());
} }
} }
@ -339,7 +339,8 @@ class CheckBox extends Component {
text: PropTypes.string.isRequired, text: PropTypes.string.isRequired,
label: PropTypes.string, label: PropTypes.string,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
format: PropTypes.string format: PropTypes.string,
className: PropTypes.string
} }
render() { render() {
@ -348,12 +349,12 @@ class CheckBox extends Component {
const id = this.props.id; const id = this.props.id;
const htmlId = 'form_' + id; const htmlId = 'form_' + id;
const className = owner.addFormValidationClass('form-check-input', id); const inputClassName = owner.addFormValidationClass('form-check-input', id);
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<div className="form-group form-check my-2"> <div className={`form-group form-check my-2 ${this.props.className}`}>
<input className={className} type="checkbox" checked={owner.getFormValue(id)} id={htmlId} aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, !owner.getFormValue(id))}/> <input className={inputClassName} type="checkbox" checked={owner.getFormValue(id)} id={htmlId} aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, !owner.getFormValue(id))}/>
<label className="form-check-label" htmlFor={htmlId}>{props.text}</label> <label className={styles.checkboxText} htmlFor={htmlId}>{props.text}</label>
</div> </div>
); );
} }

View file

@ -0,0 +1,8 @@
'use strict';
import ellipsize from "ellipsize";
export function ellipsizeBreadcrumbLabel(label) {
return ellipsize(label, 40)
}

View file

@ -13,9 +13,12 @@ import {convertToFake, getLang} from '../../../shared/langs';
import {createComponentMixin} from "./decorator-helpers"; import {createComponentMixin} from "./decorator-helpers";
import lang_en_US_common from "../../../locales/en-US/common"; import lang_en_US_common from "../../../locales/en-US/common";
import lang_es_ES_common from "../../../locales/es-ES/common";
const resourcesCommon = { const resourcesCommon = {
'en-US': lang_en_US_common, 'en-US': lang_en_US_common,
'es-ES': lang_es_ES_common,
'fk-FK': convertToFake(lang_en_US_common) 'fk-FK': convertToFake(lang_en_US_common)
}; };
@ -54,8 +57,8 @@ i18n
whitelist: mailtrainConfig.enabledLanguages, whitelist: mailtrainConfig.enabledLanguages,
load: 'currentOnly', load: 'currentOnly',
debug: true debug: false
}) });
export default i18n; export default i18n;
@ -64,7 +67,7 @@ export default i18n;
export const withTranslation = createComponentMixin([], [], (TargetClass, InnerClass) => { export const withTranslation = createComponentMixin([], [], (TargetClass, InnerClass) => {
return { return {
cls: withNamespaces()(TargetClass) cls: withNamespaces()(TargetClass)
} };
}); });
export function tMark(key) { export function tMark(key) {

View file

@ -0,0 +1,81 @@
'use strict';
import {registerDependencies, registerComponent, BodyComponent} from "mjml4-in-browser";
registerDependencies({
'mj-column': ['mj-basic-component'],
'mj-basic-component': []
});
class MjBasicComponent extends BodyComponent {
// Tell the parser that our component won't contain other mjml tags
static endingTag = true
// Tells the validator which attributes are allowed for mj-layout
static allowedAttributes = {
'stars-color': 'color',
'color': 'color',
'font-size': 'unit(px)',
'align': 'enum(left,right,center)',
}
// What the name suggests. Fallback value for this.getAttribute('attribute-name').
static defaultAttributes = {
'stars-color': 'yellow',
color: 'black',
'font-size': '12px',
'align': 'center',
}
// This functions allows to define styles that can be used when rendering (see render() below)
getStyles() {
return {
wrapperDiv: {
color: this.getAttribute('stars-color'), // this.getAttribute(attrName) is the recommended way to access the attributes our component received in the mjml
'font-size': this.getAttribute('font-size'),
},
contentP: {
'text-align': this.getAttribute('align'),
'font-size': '20px'
},
contentSpan: {
color: this.getAttribute('color')
}
}
}
/*
Render is the only required function in a component.
It must return an html string.
*/
render() {
return `
<div
${this.htmlAttributes({ // this.htmlAttributes() is the recommended way to pass attributes to html tags
class: this.getAttribute('css-class'),
style: 'wrapperDiv' // This will add the 'wrapperDiv' attributes from getStyles() as inline style
})}
>
<p ${this.htmlAttributes({
style: 'contentP' // This will add the 'contentP' attributes from getStyles() as inline style
})}>
<span></span>
<span
${this.htmlAttributes({
style: 'contentSpan' // This will add the 'contentSpan' attributes from getStyles() as inline style
})}
>
${this.getContent()}
</span>
<span></span>
</p>
</div>
`
}
}
export function registerComponents() {
registerComponent(MjBasicComponent)
}

View file

@ -94,7 +94,7 @@ export class RestActionModalDialog extends Component {
return ( return (
<ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={() => this.hideModal(true)} buttons={[ <ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={() => this.hideModal(true)} buttons={[
{ label: t('no'), className: 'btn-primary', onClickAsync: () => this.hideModal(true) }, { label: t('no'), className: 'btn-primary', onClickAsync: async () => this.hideModal(true) },
{ label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performAction } { label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performAction }
]}> ]}>
{this.props.message} {this.props.message}

View file

@ -13,9 +13,10 @@ export function needsResolve(route, nextRoute, match, nextMatch) {
const resolve = route.resolve; const resolve = route.resolve;
const nextResolve = nextRoute.resolve; const nextResolve = nextRoute.resolve;
// This compares whether two objects have the same content and returns TRUE if they don't
if (Object.keys(resolve).length === Object.keys(nextResolve).length) { if (Object.keys(resolve).length === Object.keys(nextResolve).length) {
for (const key in resolve) { for (const key in nextResolve) {
if (!(key in nextResolve) || if (!(key in resolve) ||
resolve[key](match.params) !== nextResolve[key](nextMatch.params)) { resolve[key](match.params) !== nextResolve[key](nextMatch.params)) {
return true; return true;
} }
@ -92,6 +93,8 @@ export function getRoutes(urlPrefix, resolve, parents, structure, navs, primaryM
secondaryMenuComponent: entry.secondaryMenuComponent || secondaryMenuComponent, secondaryMenuComponent: entry.secondaryMenuComponent || secondaryMenuComponent,
title: entry.title, title: entry.title,
link: entry.link, link: entry.link,
panelInFullScreen: entry.panelInFullScreen,
insideIframe: entry.insideIframe,
resolve: entryResolve, resolve: entryResolve,
parents, parents,
navs: [...navs, ...entryNavs] navs: [...navs, ...entryNavs]

View file

@ -189,7 +189,9 @@ class TertiaryNavBar extends Component {
class RouteContent extends Component { class RouteContent extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {}; this.state = {
panelInFullScreen: props.route.panelInFullScreen
};
if (Object.keys(props.route.resolve).length === 0) { if (Object.keys(props.route.resolve).length === 0) {
this.state.resolved = {}; this.state.resolved = {};
@ -200,6 +202,8 @@ class RouteContent extends Component {
this.forceUpdate(); this.forceUpdate();
} }
}; };
this.setPanelInFullScreen = panelInFullScreen => this.setState({ panelInFullScreen });
} }
static propTypes = { static propTypes = {
@ -208,7 +212,9 @@ class RouteContent extends Component {
} }
@withAsyncErrorHandler @withAsyncErrorHandler
async resolve(props) { async resolve() {
const props = this.props;
if (Object.keys(props.route.resolve).length === 0) { if (Object.keys(props.route.resolve).length === 0) {
this.setState({ this.setState({
resolved: {} resolved: {}
@ -237,18 +243,16 @@ class RouteContent extends Component {
componentDidMount() { componentDidMount() {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
this.resolve(this.props); this.resolve();
this.registerSidebarAnimationListener(); this.registerSidebarAnimationListener();
} }
componentDidUpdate() { componentDidUpdate(prevProps) {
this.registerSidebarAnimationListener(); this.registerSidebarAnimationListener();
}
componentWillReceiveProps(nextProps) { if (this.props.location.state !== prevProps.location.state || (this.props.match.params !== prevProps.match.params && needsResolve(prevProps.route, this.props.route, prevProps.match, this.props.match))) {
if (this.props.match.params !== nextProps.match.params && needsResolve(this.props.route, nextProps.route, this.props.match, nextProps.match)) {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
this.resolve(nextProps); this.resolve();
} }
} }
@ -264,6 +268,8 @@ class RouteContent extends Component {
const showSidebar = !!route.secondaryMenuComponent; const showSidebar = !!route.secondaryMenuComponent;
const panelInFullScreen = this.state.panelInFullScreen;
if (!route.panelRender && !route.panelComponent && route.link) { if (!route.panelRender && !route.panelComponent && route.link) {
let link; let link;
if (typeof route.link === 'function') { if (typeof route.link === 'function') {
@ -283,7 +289,9 @@ class RouteContent extends Component {
const compProps = { const compProps = {
match: this.props.match, match: this.props.match,
location: this.props.location, location: this.props.location,
resolved resolved,
setPanelInFullScreen: this.setPanelInFullScreen,
panelInFullScreen: this.state.panelInFullScreen
}; };
let panel; let panel;
@ -301,32 +309,50 @@ class RouteContent extends Component {
secondaryMenu = React.createElement(route.secondaryMenuComponent, compProps); secondaryMenu = React.createElement(route.secondaryMenuComponent, compProps);
} }
content = ( const panelContent = (
<> <div key="panel" className="container-fluid">
<div className="mt-breadcrumb-and-tertiary-navbar">
<Breadcrumb route={route} params={params} resolved={resolved}/>
<TertiaryNavBar route={route} params={params} resolved={resolved}/>
</div>
<div className="container-fluid">
{this.props.flashMessage} {this.props.flashMessage}
{panel} {panel}
</div> </div>
);
if (panelInFullScreen) {
content = panelContent;
} else {
content = (
<>
<div key="tertiaryNav" className="mt-breadcrumb-and-tertiary-navbar">
<Breadcrumb route={route} params={params} resolved={resolved}/>
<TertiaryNavBar route={route} params={params} resolved={resolved}/>
</div>
{panelContent}
</> </>
); );
}
} else { } else {
content = ( content = (
<div className="container-fluid"> <div className="container-fluid my-3">
{t('loading')} {t('loading')}
</div> </div>
); );
} }
if (panelInFullScreen) {
return ( return (
<div className={"app " + (showSidebar ? 'sidebar-lg-show' : '')}> <div key="app" className="app panel-in-fullscreen">
<header className="app-header"> <div key="appBody" className="app-body">
<main key="main" className="main">
{content}
</main>
</div>
</div>
);
} else {
return (
<div key="app" className={"app " + (showSidebar ? 'sidebar-lg-show' : '')}>
<header key="appHeader" className="app-header">
<nav className="navbar navbar-expand-lg navbar-dark bg-dark"> <nav className="navbar navbar-expand-lg navbar-dark bg-dark">
{showSidebar && {showSidebar &&
<button className="navbar-toggler sidebar-toggler" data-toggle="sidebar-show" type="button"> <button className="navbar-toggler sidebar-toggler" data-toggle="sidebar-show" type="button">
@ -346,24 +372,25 @@ class RouteContent extends Component {
</nav> </nav>
</header> </header>
<div className="app-body"> <div key="appBody" className="app-body">
{showSidebar && {showSidebar &&
<div className="sidebar"> <div key="sidebar" className="sidebar">
{secondaryMenu} {secondaryMenu}
</div> </div>
} }
<main className="main"> <main key="main" className="main">
{content} {content}
</main> </main>
</div> </div>
<footer className="app-footer"> <footer key="appFooter" className="app-footer">
<div className="text-muted">&copy; 2018 <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{t('sourceOnGitHub')}</a></div> <div className="text-muted">&copy; 2018 <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{t('sourceOnGitHub')}</a></div>
</footer> </footer>
</div> </div>
); );
} }
} }
}
} }
@ -656,7 +683,7 @@ export function getLanguageChooser(t) {
const label = langDesc.getLabel(t); const label = langDesc.getLabel(t);
languageOptions.push( languageOptions.push(
<DropdownActionLink key={lng} onClickAsync={() => i18n.changeLanguage(langDesc.longCode)}>{label}</DropdownActionLink> <DropdownActionLink key={lng} onClickAsync={async () => i18n.changeLanguage(langDesc.longCode)}>{label}</DropdownActionLink>
) )
} }

View file

@ -34,12 +34,16 @@ import {CodeEditorSourceType} from "./sandboxed-codeeditor-shared";
import mjml2html import mjml2html
from "mjml4-in-browser"; from "mjml4-in-browser";
import {registerComponents} from "./mjml-mosaico";
import juice import juice
from "juice"; from "juice";
import {withComponentMixins} from "./decorator-helpers"; import {withComponentMixins} from "./decorator-helpers";
const refreshTimeout = 1000; const refreshTimeout = 1000;
registerComponents();
@withComponentMixins([ @withComponentMixins([
withTranslation withTranslation
]) ])

View file

@ -82,6 +82,12 @@ class MosaicoSandbox extends Component {
}; };
}); });
// Custom convertedUrl (https://github.com/voidlabs/mosaico/blob/a359e263f1af5cf05e2c2d56c771732f2ef6c8c6/src/js/app.js#L42)
// which does not complain about mismatch of domains between TRUSTED and PUBLIC
plugins.push(viewModel => {
ko.bindingHandlers.wysiwygSrc.convertedUrl = (src, method, width, height) => getTrustedUrl(`mosaico/img?src=${encodeURIComponent(src)}&method=${encodeURIComponent(method)}&params=${width},${height}`);
});
plugins.unshift(vm => { plugins.unshift(vm => {
// This is an override of the default paths in Mosaico // This is an override of the default paths in Mosaico
vm.logoPath = getTrustedUrl('static/mosaico/img/mosaico32.png'); vm.logoPath = getTrustedUrl('static/mosaico/img/mosaico32.png');

View file

@ -2,6 +2,7 @@
.toolbar { .toolbar {
float: right; float: right;
margin-bottom: 15px;
} }
.form { // This is here to give the styles below higher priority than Bootstrap has .form { // This is here to give the styles below higher priority than Bootstrap has
@ -60,6 +61,10 @@
padding-bottom: 5px; padding-bottom: 5px;
} }
.dataTableTable {
overflow-x: auto;
}
.actionLinks > * { .actionLinks > * {
margin-right: 8px; margin-right: 8px;
} }
@ -73,7 +78,7 @@
} }
.tableSelectTable.tableSelectTableHidden { .tableSelectTable.tableSelectTableHidden {
visibility: hidden; display: none;
height: 0px; height: 0px;
margin-top: -15px; margin-top: -15px;
} }
@ -119,6 +124,10 @@
text-align: right; text-align: right;
} }
.checkboxText{
padding-top: 3px;
}
.dropZone{ .dropZone{
padding-top: 20px; padding-top: 20px;
padding-bottom: 20px; padding-bottom: 20px;

View file

@ -276,7 +276,11 @@ class Table extends Component {
const dtOptions = { const dtOptions = {
columns, columns,
pageLength: this.props.pageLength pageLength: this.props.pageLength,
dom: // This overrides Bootstrap 4 settings. It may need to be updated if there are updates in the DataTables Bootstrap 4 plugin.
"<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>>" +
"<'row'<'col-sm-12'<'" + styles.dataTableTable + "'tr>>>" +
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>"
}; };
const self = this; const self = this;

View file

@ -65,8 +65,8 @@ class TreeTable extends Component {
} }
@withAsyncErrorHandler @withAsyncErrorHandler
async loadData(dataUrl) { async loadData() {
const response = await axios.get(getUrl(dataUrl)); const response = await axios.get(getUrl(this.props.dataUrl));
const treeData = response.data; const treeData = response.data;
for (const root of treeData) { for (const root of treeData) {
@ -95,19 +95,9 @@ class TreeTable extends Component {
className: PropTypes.string className: PropTypes.string
} }
componentWillReceiveProps(nextProps) {
if (nextProps.data) {
this.setState({
treeData: nextProps.data
});
} else if (nextProps.dataUrl && this.props.dataUrl !== nextProps.dataUrl) {
// noinspection JSIgnoredPromiseFromCall
this.loadData(next.props.dataUrl);
}
}
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
return this.props.selection !== nextProps.selection || this.state.treeData != nextState.treeData || this.props.className !== nextProps.className; return this.props.selection !== nextProps.selection || this.props.data !== nextProps.data || this.props.dataUrl !== nextProps.dataUrl ||
this.state.treeData != nextState.treeData || this.props.className !== nextProps.className;
} }
// XSS protection // XSS protection
@ -129,7 +119,7 @@ class TreeTable extends Component {
componentDidMount() { componentDidMount() {
if (!this.props.data && this.props.dataUrl) { if (!this.props.data && this.props.dataUrl) {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
this.loadData(this.props.dataUrl); this.loadData();
} }
let createNodeFn; let createNodeFn;
@ -221,6 +211,15 @@ class TreeTable extends Component {
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (this.props.data) {
this.setState({
treeData: this.props.data
});
} else if (this.props.dataUrl && prevProps.dataUrl !== this.props.dataUrl) {
// noinspection JSIgnoredPromiseFromCall
this.loadData();
}
if (this.props.selection !== prevProps.selection || this.state.treeData != prevState.treeData) { if (this.props.selection !== prevProps.selection || this.state.treeData != prevState.treeData) {
if (this.state.treeData != prevState.treeData) { if (this.state.treeData != prevState.treeData) {
this.tree.reload(this.sanitizeTreeData(this.state.treeData)); this.tree.reload(this.sanitizeTreeData(this.state.treeData));

View file

@ -38,7 +38,7 @@ export class UntrustedContentHost extends Component {
this.contentNodeIsLoaded = false; this.contentNodeIsLoaded = false;
this.state = { this.state = {
hasAccessToken: false, hasAccessToken: false
}; };
this.receiveMessageHandler = ::this.receiveMessage; this.receiveMessageHandler = ::this.receiveMessage;
@ -175,7 +175,8 @@ export class UntrustedContentHost extends Component {
render() { render() {
return ( return (
<iframe className={styles.untrustedContent + ' ' + this.props.className} ref={node => this.contentNode = node} src={getSandboxUrl(this.props.contentSrc)} onLoad={::this.contentNodeLoaded}> </iframe> // The 40 px below corresponds to the height in .sandbox-loading-message
<iframe className={styles.untrustedContent + ' ' + this.props.className} height="40px" ref={node => this.contentNode = node} src={getSandboxUrl(this.props.contentSrc)} onLoad={::this.contentNodeLoaded}></iframe>
); );
} }
} }
@ -218,10 +219,10 @@ export class UntrustedContentRoot extends Component {
async receiveMessage(evt) { async receiveMessage(evt) {
const msg = evt.data; const msg = evt.data;
if (msg.type === 'initAvailable' && !this.state.initialized) { if (msg.type === 'initAvailable') {
this.sendMessage('initNeeded'); this.sendMessage('initNeeded');
} else if (msg.type === 'init' && !this.state.initialized) { } else if (msg.type === 'init') {
setRestrictedAccessToken(msg.data.accessToken); setRestrictedAccessToken(msg.data.accessToken);
this.setState({ this.setState({
initialized: true, initialized: true,
@ -255,7 +256,7 @@ export class UntrustedContentRoot extends Component {
return this.props.render(this.state.contentProps); return this.props.render(this.state.contentProps);
} else { } else {
return ( return (
<div> <div className="sandbox-loading-message">
{t('loading-1')} {t('loading-1')}
</div> </div>
); );

View file

@ -61,12 +61,15 @@ export default class CUD extends Component {
entity: PropTypes.object entity: PropTypes.object
} }
componentDidMount() { getFormValuesMutator(data) {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
data.form = data.default_form ? 'custom' : 'default'; data.form = data.default_form ? 'custom' : 'default';
data.listunsubscribe_disabled = !!data.listunsubscribe_disabled; data.listunsubscribe_disabled = !!data.listunsubscribe_disabled;
}); }
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} else { } else {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
@ -110,7 +113,7 @@ export default class CUD extends Component {
validateNamespace(t, state); validateNamespace(t, state);
} }
async submitHandler() { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -125,7 +128,7 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
if (data.form === 'default') { if (data.form === 'default') {
data.default_form = null; data.default_form = null;
} }
@ -136,8 +139,22 @@ export default class CUD extends Component {
} }
}); });
if (submitSuccessful) { if (submitResult) {
this.navigateToWithFlashMessage('/lists', 'success', t('listSaved')); if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/lists', 'success', t('List updated'));
} else {
await this.getFormValuesFromURL(`rest/lists/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('List updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/lists', 'success', t('List created'));
} else {
this.navigateToWithFlashMessage(`/lists/${submitResult}/edit`, 'success', t('List created'));
}
}
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd')); this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
@ -260,7 +277,7 @@ export default class CUD extends Component {
<Dropdown id="form" label={t('forms')} options={formsOptions} help={t('webAndEmailFormsAndTemplatesUsedIn')}/> <Dropdown id="form" label={t('forms')} options={formsOptions} help={t('webAndEmailFormsAndTemplatesUsedIn')}/>
{this.getFormValue('form') === 'custom' && {this.getFormValue('form') === 'custom' &&
<TableSelect id="default_form" label={t('customForms')} withHeader dropdown dataUrl='rest/forms-table' columns={customFormsColumns} selectionLabelIndex={1} help={<Trans i18nKey="theCustomFormUsedForThisListYouCanCreate">The custom form used for this list. You can create a form <a href={`/lists/forms/create/${this.props.entity.id}`}>here</a>.</Trans>}/> <TableSelect id="default_form" label={t('customForms')} withHeader dropdown dataUrl='rest/forms-table' columns={customFormsColumns} selectionLabelIndex={1} help={<Trans i18nKey="theCustomFormUsedForThisListYouCanCreate">The custom form used for this list. You can create a form <a href={`/lists/forms/create`}>here</a>.</Trans>}/>
} }
<CheckBox id="public_subscribe" label={t('subscription')} text={t('allowPublicUsersToSubscribeThemselves')}/> <CheckBox id="public_subscribe" label={t('subscription')} text={t('allowPublicUsersToSubscribeThemselves')}/>
@ -270,7 +287,8 @@ export default class CUD extends Component {
<CheckBox id="listunsubscribe_disabled" label={t('unsubscribeHeader')} text={t('doNotSendListUnsubscribeHeaders')}/> <CheckBox id="listunsubscribe_disabled" label={t('unsubscribeHeader')} text={t('doNotSendListUnsubscribeHeaders')}/>
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.entity.id}/delete`}/>} {canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -87,9 +87,7 @@ export default class CUD extends Component {
} }
} }
componentDidMount() { getFormValuesMutator(data) {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
data.settings = data.settings || {}; data.settings = data.settings || {};
if (data.default_value === null) { if (data.default_value === null) {
@ -130,7 +128,11 @@ export default class CUD extends Component {
data.orderListBefore = data.orderListBefore.toString(); data.orderListBefore = data.orderListBefore.toString();
data.orderSubscribeBefore = data.orderSubscribeBefore.toString(); data.orderSubscribeBefore = data.orderSubscribeBefore.toString();
data.orderManageBefore = data.orderManageBefore.toString(); data.orderManageBefore = data.orderManageBefore.toString();
}); }
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} else { } else {
this.populateFormValues({ this.populateFormValues({
@ -248,7 +250,7 @@ export default class CUD extends Component {
} }
async submitHandler() { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -264,7 +266,7 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
if (data.default_value.trim() === '') { if (data.default_value.trim() === '') {
data.default_value = null; data.default_value = null;
} }
@ -317,8 +319,22 @@ export default class CUD extends Component {
} }
}); });
if (submitSuccessful) { if (submitResult) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('fieldSaved')); if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('Field updated'));
} else {
await this.getFormValuesFromURL(`rest/fields/${this.props.list.id}/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Field updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('Field created'));
} else {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields/${submitResult}/edit`, 'success', t('Field created'));
}
}
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd')); this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
@ -508,7 +524,8 @@ export default class CUD extends Component {
} }
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/fields/${this.props.entity.id}/delete`}/>} {isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/fields/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -302,8 +302,7 @@ export default class CUD extends Component {
} }
componentDidMount() { supplyDefaults(data) {
function supplyDefaults(data) {
for (const key in mailtrainConfig.defaultCustomFormValues) { for (const key in mailtrainConfig.defaultCustomFormValues) {
if (!data[key]) { if (!data[key]) {
data[key] = mailtrainConfig.defaultCustomFormValues[key]; data[key] = mailtrainConfig.defaultCustomFormValues[key];
@ -311,10 +310,15 @@ export default class CUD extends Component {
} }
} }
getFormValuesMutator(data) {
this.supplyDefaults(data);
}
componentDidMount() {
if (this.props.entity) { if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => { this.getFormValuesFromEntity(this.props.entity, data => {
this.getFormValuesMutator(data);
data.selectedTemplate = 'layout'; data.selectedTemplate = 'layout';
supplyDefaults(data);
}); });
} else { } else {
@ -324,7 +328,7 @@ export default class CUD extends Component {
selectedTemplate: 'layout', selectedTemplate: 'layout',
namespace: mailtrainConfig.user.namespace namespace: mailtrainConfig.user.namespace
}; };
supplyDefaults(data); this.supplyDefaults(data);
this.populateFormValues(data); this.populateFormValues(data);
} }
@ -370,7 +374,7 @@ export default class CUD extends Component {
} }
} }
async submitHandler() { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -385,13 +389,27 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
delete data.selectedTemplate; delete data.selectedTemplate;
delete data.previewList; delete data.previewList;
}); });
if (submitSuccessful) { if (submitResult) {
this.navigateToWithFlashMessage('/lists/forms', 'success', t('formsSaved')); if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/lists/forms', 'success', t('Custom forms updated'));
} else {
await this.getFormValuesFromURL(`rest/forms/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Custom forms updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/lists/forms', 'success', t('Custom forms created'));
} else {
this.navigateToWithFlashMessage(`/lists/forms/${submitResult}/edit`, 'success', t('Custom forms created'));
}
}
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd')); this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
@ -410,6 +428,7 @@ export default class CUD extends Component {
const response = await axios.post(getUrl('rest/forms-preview'), data); const response = await axios.post(getUrl('rest/forms-preview'), data);
this.setState({ this.setState({
previewKey: formKey,
previewContents: response.data.content, previewContents: response.data.content,
previewLabel: this.templateSettings[formKey].label previewLabel: this.templateSettings[formKey].label
}); });
@ -504,10 +523,15 @@ export default class CUD extends Component {
{this.state.previewContents && {this.state.previewContents &&
<div className={this.state.previewFullscreen ? formsStyles.editorFullscreen : formsStyles.editor}> <div className={this.state.previewFullscreen ? formsStyles.editorFullscreen : formsStyles.editor}>
<div className={formsStyles.navbar}> <div className={formsStyles.navbar}>
<div className={formsStyles.navbarLeft}>
{this.state.fullscreen && <img className={formsStyles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>} {this.state.fullscreen && <img className={formsStyles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={formsStyles.title}>{t('formPreview') + ' ' + this.state.previewLabel}</div> <div className={formsStyles.title}>{t('formPreview') + ' ' + this.state.previewLabel}</div>
<a className={formsStyles.btn} onClick={() => this.setState({previewContents: null, previewFullscreen: false})}><Icon icon="window-close"/></a> </div>
<a className={formsStyles.btn} onClick={() => this.setState({previewFullscreen: !this.state.previewFullscreen})}><Icon icon="window-maximize"/></a> <div className={formsStyles.navbarRight}>
<a className={formsStyles.btn} onClick={() => this.preview(this.state.previewKey)} title={t('Refresh')}><Icon icon="sync-alt"/></a>
<a className={formsStyles.btn} onClick={() => this.setState({previewFullscreen: !this.state.previewFullscreen})} title={t('Maximize editor')}><Icon icon="window-maximize"/></a>
<a className={formsStyles.btn} onClick={() => this.setState({previewContents: null, previewFullscreen: false})} title={t('Close preview')}><Icon icon="window-close"/></a>
</div>
</div> </div>
<iframe className={formsStyles.host} src={"data:text/html;charset=utf-8," + encodeURIComponent(this.state.previewContents)}></iframe> <iframe className={formsStyles.host} src={"data:text/html;charset=utf-8," + encodeURIComponent(this.state.previewContents)}></iframe>
</div> </div>
@ -524,7 +548,8 @@ export default class CUD extends Component {
} }
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/forms/${this.props.entity.id}/delete`}/>} {canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/forms/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -92,7 +92,7 @@ export default class Status extends Component {
const columns = [ const columns = [
{ data: 1, title: t('row') }, { data: 1, title: t('row') },
{ data: 2, title: t('email') }, { data: 2, title: t('email') },
{ data: 3, title: t('reason') } { data: 3, title: t('reason'), render: data => t(...JSON.parse(data)) }
]; ];
return ( return (

View file

@ -150,7 +150,7 @@ export default class Status extends Component {
} }
actions.push({ actions.push({
label: <Icon icon="eye-open" title={t('runStatus')}/>, label: <Icon icon="eye" title={t('runStatus')}/>,
link: `/lists/${this.props.list.id}/imports/${this.props.entity.id}/status/${data[0]}` link: `/lists/${this.props.list.id}/imports/${this.props.entity.id}/status/${data[0]}`
}); });

View file

@ -18,6 +18,7 @@ import ImportsStatus from './imports/Status';
import ImportRunsStatus from './imports/RunStatus'; import ImportRunsStatus from './imports/RunStatus';
import Share from '../shares/Share'; import Share from '../shares/Share';
import TriggersList from './TriggersList'; import TriggersList from './TriggersList';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
function getMenus(t) { function getMenus(t) {
return { return {
@ -27,7 +28,7 @@ function getMenus(t) {
panelComponent: ListsList, panelComponent: ListsList,
children: { children: {
':listId([0-9]+)': { ':listId([0-9]+)': {
title: resolved => t('listName', {name: resolved.list.name}), title: resolved => t('listName', {name: ellipsizeBreadcrumbLabel(resolved.list.name)}),
resolve: { resolve: {
list: params => `rest/lists/${params.listId}` list: params => `rest/lists/${params.listId}`
}, },
@ -78,7 +79,7 @@ function getMenus(t) {
panelRender: props => <FieldsList list={props.resolved.list} />, panelRender: props => <FieldsList list={props.resolved.list} />,
children: { children: {
':fieldId([0-9]+)': { ':fieldId([0-9]+)': {
title: resolved => t('fieldName-1', {name: resolved.field.name}), title: resolved => t('fieldName-1', {name: ellipsizeBreadcrumbLabel(resolved.field.name)}),
resolve: { resolve: {
field: params => `rest/fields/${params.listId}/${params.fieldId}`, field: params => `rest/fields/${params.listId}/${params.fieldId}`,
fields: params => `rest/fields/${params.listId}` fields: params => `rest/fields/${params.listId}`
@ -108,7 +109,7 @@ function getMenus(t) {
panelRender: props => <SegmentsList list={props.resolved.list} />, panelRender: props => <SegmentsList list={props.resolved.list} />,
children: { children: {
':segmentId([0-9]+)': { ':segmentId([0-9]+)': {
title: resolved => t('segmentName', {name: resolved.segment.name}), title: resolved => t('segmentName', {name: ellipsizeBreadcrumbLabel(resolved.segment.name)}),
resolve: { resolve: {
segment: params => `rest/segments/${params.listId}/${params.segmentId}`, segment: params => `rest/segments/${params.listId}/${params.segmentId}`,
fields: params => `rest/fields/${params.listId}` fields: params => `rest/fields/${params.listId}`
@ -138,7 +139,7 @@ function getMenus(t) {
panelRender: props => <ImportsList list={props.resolved.list} />, panelRender: props => <ImportsList list={props.resolved.list} />,
children: { children: {
':importId([0-9]+)': { ':importId([0-9]+)': {
title: resolved => t('importName-1', {name: resolved.import.name}), title: resolved => t('importName-1', {name: ellipsizeBreadcrumbLabel(resolved.import.name)}),
resolve: { resolve: {
import: params => `rest/imports/${params.listId}/${params.importId}`, import: params => `rest/imports/${params.listId}/${params.importId}`,
}, },
@ -198,7 +199,7 @@ function getMenus(t) {
panelComponent: FormsList, panelComponent: FormsList,
children: { children: {
':formsId([0-9]+)': { ':formsId([0-9]+)': {
title: resolved => t('customFormsName', {name: resolved.forms.name}), title: resolved => t('customFormsName', {name: ellipsizeBreadcrumbLabel(resolved.forms.name)}),
resolve: { resolve: {
forms: params => `rest/forms/${params.formsId}` forms: params => `rest/forms/${params.formsId}`
}, },

View file

@ -119,16 +119,18 @@ export default class CUD extends Component {
return tree; return tree;
} }
componentDidMount() { getFormValuesMutator(data) {
if (this.props.entity) {
this.setState({
rulesTree: this.getTreeFromRules(this.props.entity.settings.rootRule.rules)
});
this.getFormValuesFromEntity(this.props.entity, data => {
data.rootRuleType = data.settings.rootRule.type; data.rootRuleType = data.settings.rootRule.type;
data.selectedRule = null; // Validation errors of the selected rule are attached to this which makes sure we don't submit the segment if the opened rule has errors data.selectedRule = null; // Validation errors of the selected rule are attached to this which makes sure we don't submit the segment if the opened rule has errors
this.setState({
rulesTree: this.getTreeFromRules(data.settings.rootRule.rules)
}); });
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} else { } else {
this.populateFormValues({ this.populateFormValues({
@ -159,7 +161,7 @@ export default class CUD extends Component {
} }
} }
async doSubmit(stay) { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -175,7 +177,7 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
const keep = ['name', 'settings', 'originalHash']; const keep = ['name', 'settings', 'originalHash'];
data.settings.rootRule.type = data.rootRuleType; data.settings.rootRule.type = data.rootRuleType;
@ -184,20 +186,22 @@ export default class CUD extends Component {
delete data.selectedRule; delete data.selectedRule;
}); });
if (submitSuccessful) { if (submitResult) {
if (stay) { if (this.props.entity) {
await this.getFormValuesFromURL(`rest/segments/${this.props.list.id}/${this.props.entity.id}`, data => { if (submitAndLeave) {
data.rootRuleType = data.settings.rootRule.type; this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('Segment updated'));
data.selectedRule = null; // Validation errors of the selected rule are attached to this which makes sure we don't submit the segment if the opened rule has errors
this.setState({
rulesTree: this.getTreeFromRules(data.settings.rootRule.rules)
});
});
this.enableForm();
this.setFormStatusMessage('success', t('segmentSaved'));
} else { } else {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('segmentSaved')); await this.getFormValuesFromURL(`rest/segments/${this.props.list.id}/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Segment updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('Segment created'));
} else {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments/${submitResult}/edit`, 'success', t('Segment created'));
}
} }
} else { } else {
this.enableForm(); this.enableForm();
@ -208,14 +212,6 @@ export default class CUD extends Component {
} }
} }
async submitAndStay() {
await this.formHandleChangedError(async () => await this.doSubmit(true));
}
async submitAndLeave() {
await this.formHandleChangedError(async () => await this.doSubmit(false));
}
onRulesChanged(rulesTree) { onRulesChanged(rulesTree) {
// This assumes that !this.state.ruleOptionsVisible // This assumes that !this.state.ruleOptionsVisible
this.getFormValue('settings').rootRule.rules = this.getRulesFromTree(rulesTree); this.getFormValue('settings').rootRule.rules = this.getRulesFromTree(rulesTree);
@ -354,7 +350,7 @@ export default class CUD extends Component {
<Title>{isEdit ? t('editSegment') : t('createSegment')}</Title> <Title>{isEdit ? t('editSegment') : t('createSegment')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<h3>{t('segmentOptions')}</h3> <h3>{t('segmentOptions')}</h3>
<InputField id="name" label={t('name')} /> <InputField id="name" label={t('name')} />
@ -407,19 +403,12 @@ export default class CUD extends Component {
</div> </div>
<hr/> <hr/>
{isEdit ? <ButtonRow format="wide" className={`col-12 ${styles.toolbar}`}>
<ButtonRow format="wide" className={`col-xs-12 ${styles.toolbar}`}> <Button type="submit" className="btn-primary" icon="check" label={t('save')} onClickAsync={async () => this.submitHandler(false)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndStay')} onClickAsync={::this.submitAndStay}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={::this.submitAndLeave}/>
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/segments/${this.props.entity.id}/delete`}/> {isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/segments/${this.props.entity.id}/delete`}/> }
</ButtonRow> </ButtonRow>
:
<ButtonRow format="wide" className={`col-xs-12 ${styles.toolbar}`}>
<Button type="submit" className="btn-primary" icon="check" label={t('save')} onClickAsync={::this.submitAndLeave}/>
</ButtonRow>
}
</div> </div>
); );
} }

View file

@ -69,16 +69,18 @@ export default class CUD extends Component {
entity: PropTypes.object entity: PropTypes.object
} }
componentDidMount() { getFormValuesMutator(data) {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
data.status = data.status.toString(); data.status = data.status.toString();
data.tz = data.tz || ''; data.tz = data.tz || '';
for (const fld of this.props.fieldsGrouped) { for (const fld of this.props.fieldsGrouped) {
this.fieldTypes[fld.type].assignFormData(fld, data); this.fieldTypes[fld.type].assignFormData(fld, data);
} }
}); }
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} else { } else {
const data = { const data = {
@ -115,7 +117,7 @@ export default class CUD extends Component {
} }
} }
async submitHandler() { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -131,7 +133,7 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
data.status = parseInt(data.status); data.status = parseInt(data.status);
data.tz = data.tz || null; data.tz = data.tz || null;
@ -140,8 +142,22 @@ export default class CUD extends Component {
} }
}); });
if (submitSuccessful) { if (submitResult) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions`, 'success', t('susbscriptionSaved')); if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions`, 'success', t('Subscription updated'));
} else {
await this.getFormValuesFromURL(`rest/subscriptions/${this.props.list.id}/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Subscription updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions`, 'success', t('Subscription created'));
} else {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions/${submitResult}/edit`, 'success', t('Subscription created'));
}
}
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd')); this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
@ -222,7 +238,8 @@ export default class CUD extends Component {
</AlignedRow> </AlignedRow>
} }
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/subscriptions/${this.props.entity.id}/delete`}/>} {isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/subscriptions/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -82,6 +82,8 @@ export default class Login extends Component {
/* This ensures we get config for the authenticated user */ /* This ensures we get config for the authenticated user */
window.location = nextUrl; window.location = nextUrl;
} else { } else {
this.enableForm();
this.setFormStatusMessage('warning', t('pleaseEnterYourCredentialsAndTryAgain')); this.setFormStatusMessage('warning', t('pleaseEnterYourCredentialsAndTryAgain'));
} }
} catch (error) { } catch (error) {

View file

@ -77,6 +77,7 @@ export default class CUD extends Component {
@withAsyncErrorHandler @withAsyncErrorHandler
async loadTreeData() { async loadTreeData() {
if (!this.isEditGlobal()) {
const response = await axios.get(getUrl('rest/namespaces-tree')); const response = await axios.get(getUrl('rest/namespaces-tree'));
const data = response.data; const data = response.data;
for (const root of data) { for (const root of data) {
@ -91,6 +92,7 @@ export default class CUD extends Component {
treeData: data treeData: data
}); });
} }
}
componentDidMount() { componentDidMount() {
if (this.props.entity) { if (this.props.entity) {
@ -103,11 +105,9 @@ export default class CUD extends Component {
}); });
} }
if (!this.isEditGlobal()) {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
this.loadTreeData(); this.loadTreeData();
} }
}
localValidateFormValues(state) { localValidateFormValues(state) {
const t = this.props.t; const t = this.props.t;
@ -127,7 +127,7 @@ export default class CUD extends Component {
} }
} }
async submitHandler() { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -143,10 +143,26 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url); const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitSuccessful) { if (submitResult) {
this.navigateToWithFlashMessage('/namespaces', 'success', t('namespaceSaved')); if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace updated'));
} else {
await this.getFormValuesFromURL(`rest/namespaces/${this.props.entity.id}`);
await this.loadTreeData();
this.enableForm();
this.setFormStatusMessage('success', t('Namespace updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace created'));
} else {
this.navigateToWithFlashMessage(`/namespaces/${submitResult}/edit`, 'success', t('Namespace created'));
}
}
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd')); this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
@ -205,7 +221,8 @@ export default class CUD extends Component {
<TreeTableSelect id="namespace" label={t('parentNamespace')} data={this.state.treeData}/>} <TreeTableSelect id="namespace" label={t('parentNamespace')} data={this.state.treeData}/>}
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/namespaces/${this.props.entity.id}/delete`}/>} {canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/namespaces/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -4,6 +4,7 @@ import React from 'react';
import CUD from './CUD'; import CUD from './CUD';
import List from './List'; import List from './List';
import Share from '../shares/Share'; import Share from '../shares/Share';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
function getMenus(t) { function getMenus(t) {
return { return {
@ -13,7 +14,7 @@ function getMenus(t) {
panelComponent: List, panelComponent: List,
children: { children: {
':namespaceId([0-9]+)': { ':namespaceId([0-9]+)': {
title: resolved => t('namespaceName', {name: resolved.namespace.name}), title: resolved => t('namespaceName', {name: ellipsizeBreadcrumbLabel(resolved.namespace.name)}),
resolve: { resolve: {
namespace: params => `rest/namespaces/${params.namespaceId}` namespace: params => `rest/namespaces/${params.namespaceId}`
}, },

View file

@ -82,13 +82,16 @@ export default class CUD extends Component {
} }
} }
componentDidMount() { getFormValuesMutator(data) {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
for (const key in data.params) { for (const key in data.params) {
data[`param_${key}`] = data.params[key]; data[`param_${key}`] = data.params[key];
} }
}); }
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} else { } else {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
@ -145,7 +148,7 @@ export default class CUD extends Component {
validateNamespace(t, state); validateNamespace(t, state);
} }
async submitHandler() { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
if (this.getFormValue('report_template') && !this.getFormValue('user_fields')) { if (this.getFormValue('report_template') && !this.getFormValue('user_fields')) {
@ -165,7 +168,7 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
const params = {}; const params = {};
for (const spec of data.user_fields) { for (const spec of data.user_fields) {
@ -178,8 +181,22 @@ export default class CUD extends Component {
data.params = params; data.params = params;
}); });
if (submitSuccessful) { if (submitResult) {
this.navigateToWithFlashMessage('/reports', 'success', t('reportSaved')); if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/reports', 'success', t('Report updated'));
} else {
await this.getFormValuesFromURL(`rest/reports/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Report updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/reports', 'success', t('Report created'));
} else {
this.navigateToWithFlashMessage(`/reports/${submitResult}/edit`, 'success', t('Report created'));
}
}
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd')); this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
@ -274,7 +291,8 @@ export default class CUD extends Component {
} }
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{canDelete && {canDelete &&
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/reports/${this.props.entity.id}/delete`}/> <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/reports/${this.props.entity.id}/delete`}/>
} }

View file

@ -9,6 +9,7 @@ import ReportTemplatesList from './templates/List';
import Share from '../shares/Share'; import Share from '../shares/Share';
import {ReportState} from '../../../shared/reports'; import {ReportState} from '../../../shared/reports';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
function getMenus(t) { function getMenus(t) {
@ -19,7 +20,7 @@ function getMenus(t) {
panelComponent: ReportsList, panelComponent: ReportsList,
children: { children: {
':reportId([0-9]+)': { ':reportId([0-9]+)': {
title: resolved => t('reportName-1', {name: resolved.report.name}), title: resolved => t('reportName-1', {name: ellipsizeBreadcrumbLabel(resolved.report.name)}),
resolve: { resolve: {
report: params => `rest/reports/${params.reportId}` report: params => `rest/reports/${params.reportId}`
}, },
@ -66,7 +67,7 @@ function getMenus(t) {
panelComponent: ReportTemplatesList, panelComponent: ReportTemplatesList,
children: { children: {
':templateId([0-9]+)': { ':templateId([0-9]+)': {
title: resolved => t('templateName', {name: resolved.template.name}), title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}),
resolve: { resolve: {
template: params => `rest/report-templates/${params.templateId}` template: params => `rest/report-templates/${params.templateId}`
}, },

View file

@ -262,15 +262,7 @@ export default class CUD extends Component {
validateNamespace(t, state); validateNamespace(t, state);
} }
async submitAndStay() { async submitHandler(submitAndLeave) {
await this.formHandleChangedError(async () => await this.doSubmit(true));
}
async submitAndLeave() {
await this.formHandleChangedError(async () => await this.doSubmit(false));
}
async doSubmit(stay) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -285,15 +277,23 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url); const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitSuccessful) { if (submitResult) {
if (stay) { if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/reports/templates', 'success', t('Report template updated'));
} else {
await this.getFormValuesFromURL(`rest/report-templates/${this.props.entity.id}`); await this.getFormValuesFromURL(`rest/report-templates/${this.props.entity.id}`);
this.enableForm(); this.enableForm();
this.setFormStatusMessage('success', t('reportTemplateSaved')); this.setFormStatusMessage('success', t('Report template updated'));
}
} else { } else {
this.navigateToWithFlashMessage('/reports/templates', 'success', t('reportTemplateSaved')); if (submitAndLeave) {
this.navigateToWithFlashMessage('/reports/templates', 'success', t('Report template created'));
} else {
this.navigateToWithFlashMessage(`/reports/templates/${submitResult}/edit`, 'success', t('Report template created'));
}
} }
} else { } else {
this.enableForm(); this.enableForm();
@ -321,7 +321,7 @@ export default class CUD extends Component {
<Title>{isEdit ? t('editReportTemplate') : t('createReportTemplate')}</Title> <Title>{isEdit ? t('editReportTemplate') : t('createReportTemplate')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('name')}/> <InputField id="name" label={t('name')}/>
<TextArea id="description" label={t('description')}/> <TextArea id="description" label={t('description')}/>
<Dropdown id="mime_type" label={t('type')} options={[{key: 'text/html', label: t('html')}, {key: 'text/csv', label: t('csv')}]}/> <Dropdown id="mime_type" label={t('type')} options={[{key: 'text/html', label: t('html')}, {key: 'text/csv', label: t('csv')}]}/>
@ -330,19 +330,13 @@ export default class CUD extends Component {
<ACEEditor id="js" height="700px" mode="javascript" label={t('dataProcessingCode')} help={<Trans i18nKey="writeTheBodyOfTheJavaScriptFunctionWith">Write the body of the JavaScript function with signature <code>async function(inputs)</code> that returns an object to be rendered by the Handlebars template below.</Trans>}/> <ACEEditor id="js" height="700px" mode="javascript" label={t('dataProcessingCode')} help={<Trans i18nKey="writeTheBodyOfTheJavaScriptFunctionWith">Write the body of the JavaScript function with signature <code>async function(inputs)</code> that returns an object to be rendered by the Handlebars template below.</Trans>}/>
<ACEEditor id="hbs" height="700px" mode="handlebars" label={t('renderingTemplate')} help={<Trans i18nKey="useHtmlWithHandlebarsSyntaxSee">Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</Trans>}/> <ACEEditor id="hbs" height="700px" mode="handlebars" label={t('renderingTemplate')} help={<Trans i18nKey="useHtmlWithHandlebarsSyntaxSee">Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</Trans>}/>
{isEdit ?
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndStay')} onClickAsync={::this.submitAndStay}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{canDelete && {canDelete &&
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/reports/templates/${this.props.entity.id}/delete`}/> <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/reports/templates/${this.props.entity.id}/delete`}/>
} }
</ButtonRow> </ButtonRow>
:
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
</ButtonRow>
}
</Form> </Form>
</div> </div>
); );

View file

@ -108,7 +108,7 @@ class Root extends Component {
<ul className="navbar-nav mt-navbar-nav-left"> <ul className="navbar-nav mt-navbar-nav-left">
{topLevelMenu} {topLevelMenu}
<NavDropdown label={t('administration')}> <NavDropdown label={t('administration')}>
<DropdownLink to="/users">{t('users')}</DropdownLink> {mailtrainConfig.globalPermissions.displayManageUsers && <DropdownLink to="/users">{t('users')}</DropdownLink>}
<DropdownLink to="/namespaces">{t('namespaces')}</DropdownLink> <DropdownLink to="/namespaces">{t('namespaces')}</DropdownLink>
{mailtrainConfig.globalPermissions.manageSettings && <DropdownLink to="/settings">{t('globalSettings')}</DropdownLink>} {mailtrainConfig.globalPermissions.manageSettings && <DropdownLink to="/settings">{t('globalSettings')}</DropdownLink>}
<DropdownLink to="/send-configurations">{t('sendConfigurations')}</DropdownLink> <DropdownLink to="/send-configurations">{t('sendConfigurations')}</DropdownLink>

View file

@ -1,4 +1,10 @@
@import url('https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,400i,700,700i|Ubuntu:300,300i,400,400i,700,700i&subset=latin-ext');
$font-family-sans-serif: 'Ubuntu', sans-serif;
$font-family-monospace: 'Ubuntu Mono', monospace;
$fa-font-path: "../static-npm/fontawesome"; $fa-font-path: "../static-npm/fontawesome";
$enable-print-styles: false;
@import "./variables.scss"; @import "./variables.scss";
@import "node_modules/@coreui/coreui/scss/coreui.scss"; @import "node_modules/@coreui/coreui/scss/coreui.scss";
@ -13,6 +19,19 @@ $fa-font-path: "../static-npm/fontawesome";
body.mailtrain { body.mailtrain {
background-color: white; background-color: white;
&.sandbox {
overflow-x: hidden;
}
&.inside-iframe {
overflow: hidden;
}
.sandbox-loading-message {
// The 40 px below corresponds to the height in in UntrustedContentHost.render
height: 40px;
}
.dropdown-item { .dropdown-item {
border-bottom: none 0px; border-bottom: none 0px;
} }

View file

@ -40,6 +40,8 @@ import {
import styles import styles
from "../lib/styles.scss"; from "../lib/styles.scss";
import sendConfigurationsStyles from "./styles.scss";
import mailtrainConfig import mailtrainConfig
from 'mailtrainConfig'; from 'mailtrainConfig';
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
@ -80,15 +82,16 @@ export default class CUD extends Component {
} }
componentDidMount() { getFormValuesMutator(data) {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
this.mailerTypes[data.mailer_type].afterLoad(data); this.mailerTypes[data.mailer_type].afterLoad(data);
data.verpEnabled = !!data.verp_hostname; data.verpEnabled = !!data.verp_hostname;
data.verp_hostname = data.verp_hostname || ''; data.verp_hostname = data.verp_hostname || '';
data.verp_disable_sender_header = data.verpEnabled ? !!data.verp_disable_sender_header : false; data.verp_disable_sender_header = data.verpEnabled ? !!data.verp_disable_sender_header : false;
}); }
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} else { } else {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
@ -142,7 +145,7 @@ export default class CUD extends Component {
} }
} }
async submitHandler() { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -157,7 +160,7 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
this.mailerTypes[data.mailer_type].beforeSave(data); this.mailerTypes[data.mailer_type].beforeSave(data);
if (!data.verpEnabled) { if (!data.verpEnabled) {
data.verp_hostname = null; data.verp_hostname = null;
@ -165,8 +168,22 @@ export default class CUD extends Component {
} }
}); });
if (submitSuccessful) { if (submitResult) {
this.navigateToWithFlashMessage('/send-configurations', 'success', t('sendConfigurationSaved')); if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/send-configurations', 'success', t('Send configuration updated'));
} else {
await this.getFormValuesFromURL(`rest/send-configurations-private/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Send configuration updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/send-configurations', 'success', t('Send configuration created'));
} else {
this.navigateToWithFlashMessage(`/send-configurations/${submitResult}/edit`, 'success', t('Send configuration created'));
}
}
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd')); this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
@ -216,13 +233,13 @@ export default class CUD extends Component {
<Fieldset label={t('emailHeader')}> <Fieldset label={t('emailHeader')}>
<InputField id="from_email" label={t('defaultFromEmail')}/> <InputField id="from_email" label={t('defaultFromEmail')}/>
<CheckBox id="from_email_overridable" text={t('overridable')}/> <CheckBox id="from_email_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
<InputField id="from_name" label={t('defaultFromName')}/> <InputField id="from_name" label={t('defaultFromName')}/>
<CheckBox id="from_name_overridable" text={t('overridable')}/> <CheckBox id="from_name_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
<InputField id="reply_to" label={t('defaultReplytoEmail')}/> <InputField id="reply_to" label={t('defaultReplytoEmail')}/>
<CheckBox id="reply_to_overridable" text={t('overridable')}/> <CheckBox id="reply_to_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
<InputField id="subject" label={t('subject')}/> <InputField id="subject" label={t('subject')}/>
<CheckBox id="subject_overridable" text={t('overridable')}/> <CheckBox id="subject_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
<InputField id="x_mailer" label={t('xMailer')}/> <InputField id="x_mailer" label={t('xMailer')}/>
</Fieldset> </Fieldset>
@ -247,7 +264,8 @@ export default class CUD extends Component {
<hr/> <hr/>
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{canDelete && {canDelete &&
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/send-configurations/${this.props.entity.id}/delete`}/> <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/send-configurations/${this.props.entity.id}/delete`}/>
} }

View file

@ -5,6 +5,7 @@ import React from 'react';
import CUD from './CUD'; import CUD from './CUD';
import List from './List'; import List from './List';
import Share from '../shares/Share'; import Share from '../shares/Share';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
function getMenus(t) { function getMenus(t) {
@ -15,7 +16,7 @@ function getMenus(t) {
panelComponent: List, panelComponent: List,
children: { children: {
':sendConfigurationId([0-9]+)': { ':sendConfigurationId([0-9]+)': {
title: resolved => t('templateName', {name: resolved.sendConfiguration.name}), title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.sendConfiguration.name)}),
resolve: { resolve: {
sendConfiguration: params => `rest/send-configurations-private/${params.sendConfigurationId}` sendConfiguration: params => `rest/send-configurations-private/${params.sendConfigurationId}`
}, },

View file

@ -1,3 +1,7 @@
textarea.dkimPrivateKey { textarea.dkimPrivateKey {
height: 200px; height: 200px;
} }
.overridableCheckbox {
margin-top: -8px !important;
}

View file

@ -12,12 +12,12 @@ import {
} from '../lib/page' } from '../lib/page'
import { import {
Button, Button,
ButtonRow, ButtonRow, CheckBox,
Dropdown, Dropdown,
Form, Form,
FormSendMethod, FormSendMethod,
InputField, InputField,
StaticField, StaticField, TableSelect,
TextArea, TextArea,
withForm withForm
} from '../lib/form'; } from '../lib/form';
@ -41,6 +41,8 @@ import styles
import {getUrl} from "../lib/urls"; import {getUrl} from "../lib/urls";
import {TestSendModalDialog} from "./TestSendModalDialog"; import {TestSendModalDialog} from "./TestSendModalDialog";
import {withComponentMixins} from "../lib/decorator-helpers"; import {withComponentMixins} from "../lib/decorator-helpers";
import moment
from 'moment';
@withComponentMixins([ @withComponentMixins([
@ -74,7 +76,8 @@ export default class CUD extends Component {
static propTypes = { static propTypes = {
action: PropTypes.string.isRequired, action: PropTypes.string.isRequired,
wizard: PropTypes.string, wizard: PropTypes.string,
entity: PropTypes.object entity: PropTypes.object,
setPanelInFullScreen: PropTypes.func
} }
onTypeChanged(mutStateData, key, oldType, type) { onTypeChanged(mutStateData, key, oldType, type) {
@ -83,19 +86,24 @@ export default class CUD extends Component {
} }
} }
loadFromEntityMutator(data) { getFormValuesMutator(data) {
this.templateTypes[data.type].afterLoad(data); this.templateTypes[data.type].afterLoad(data);
} }
componentDidMount() { componentDidMount() {
if (this.props.entity) { if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => this.loadFromEntityMutator(data)); this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} else { } else {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
description: '', description: '',
namespace: mailtrainConfig.user.namespace, namespace: mailtrainConfig.user.namespace,
type: mailtrainConfig.editors[0], type: mailtrainConfig.editors[0],
fromSourceTemplate: false,
sourceTemplate: null,
text: '', text: '',
html: '', html: '',
data: {}, data: {},
@ -120,6 +128,12 @@ export default class CUD extends Component {
state.setIn(['type', 'error'], t('typeMustBeSelected')); state.setIn(['type', 'error'], t('typeMustBeSelected'));
} }
if (state.getIn(['fromSourceTemplate', 'value']) && !state.getIn(['sourceTemplate', 'value'])) {
state.setIn(['sourceTemplate', 'error'], t('Source template must not be empty'));
} else {
state.setIn(['sourceTemplate', 'error'], null);
}
validateNamespace(t, state); validateNamespace(t, state);
if (typeKey) { if (typeKey) {
@ -127,7 +141,11 @@ export default class CUD extends Component {
} }
} }
async doSave(stayOnPage) { async save() {
await this.submitHandler();
}
async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let exportedData = {}; let exportedData = {};
@ -148,23 +166,26 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
Object.assign(data, exportedData); Object.assign(data, exportedData);
this.templateTypes[data.type].beforeSave(data); this.templateTypes[data.type].beforeSave(data);
}); });
if (submitResponse) { if (submitResult) {
if (stayOnPage) { if (this.props.entity) {
await this.getFormValuesFromURL(`rest/templates/${this.props.entity.id}`, data => this.loadFromEntityMutator(data)); if (submitAndLeave) {
this.enableForm(); this.navigateToWithFlashMessage('/templates', 'success', t('Template updated'));
this.clearFormStatusMessage();
this.setFlashMessage('success', t('templateSaved'));
} else if (this.props.entity) {
this.navigateToWithFlashMessage('/templates', 'success', t('templateSaved'));
} else { } else {
this.navigateToWithFlashMessage(`/templates/${submitResponse}/edit`, 'success', t('templateSaved')); await this.getFormValuesFromURL(`rest/templates/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Template updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/templates', 'success', t('Template created'));
} else {
this.navigateToWithFlashMessage(`/templates/${submitResult}/edit`, 'success', t('Template created'));
}
} }
} else { } else {
this.enableForm(); this.enableForm();
@ -172,14 +193,6 @@ export default class CUD extends Component {
} }
} }
async save() {
await this.doSave(true);
}
async submitHandler() {
await this.doSave(false);
}
async extractPlainText() { async extractPlainText() {
const typeKey = this.getFormValue('type'); const typeKey = this.getFormValue('type');
const exportedData = await this.templateTypes[typeKey].exportHTMLEditorData(this); const exportedData = await this.templateTypes[typeKey].exportHTMLEditorData(this);
@ -209,6 +222,7 @@ export default class CUD extends Component {
} }
async setElementInFullscreen(elementInFullscreen) { async setElementInFullscreen(elementInFullscreen) {
this.props.setPanelInFullScreen(elementInFullscreen);
this.setState({ this.setState({
elementInFullscreen elementInFullscreen
}); });
@ -252,6 +266,13 @@ export default class CUD extends Component {
typeForm = getTypeForm(this, typeKey, isEdit); typeForm = getTypeForm(this, typeKey, isEdit);
} }
const templatesColumns = [
{ 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') },
];
return ( return (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}> <div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
@ -278,8 +299,15 @@ export default class CUD extends Component {
<InputField id="name" label={t('name')}/> <InputField id="name" label={t('name')}/>
<TextArea id="description" label={t('description')}/> <TextArea id="description" label={t('description')}/>
{isEdit {!isEdit &&
? <CheckBox id="fromSourceTemplate" label={t('template')} text={t('Clone from an existing template')}/>
}
{this.getFormValue('fromSourceTemplate') ?
<TableSelect key="templateSelect" id="sourceTemplate" withHeader dropdown dataUrl='rest/templates-table' columns={templatesColumns} selectionLabelIndex={1} />
:
<>
{isEdit ?
<StaticField id="type" className={styles.formDisabled} label={t('type')}> <StaticField id="type" className={styles.formDisabled} label={t('type')}>
{typeKey && this.templateTypes[typeKey].typeName} {typeKey && this.templateTypes[typeKey].typeName}
</StaticField> </StaticField>
@ -288,15 +316,18 @@ export default class CUD extends Component {
} }
{typeForm} {typeForm}
</>
}
<NamespaceSelect/> <NamespaceSelect/>
{editForm} {editForm}
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={isEdit ? t('save') : t('saveAndEditTemplate')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
{isEdit && <Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>}
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/templates/${this.props.entity.id}/delete`}/> } {canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/templates/${this.props.entity.id}/delete`}/> }
{isEdit && <Button className="btn-danger" icon="send" label={t('testSend')} onClickAsync={async () => this.setState({showTestSendModal: true})}/> } {isEdit && <Button className="btn-success" icon="at" label={t('testSend')} onClickAsync={async () => this.setState({showTestSendModal: true})}/> }
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -141,8 +141,8 @@ export class TestSendModalDialog extends Component {
return ( return (
<ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[ <ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('send'), className: 'btn-danger', onClickAsync: ::this.performAction }, { label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
{ label: t('cancel'), className: 'btn-primary', onClickAsync: ::this.hideModal } { label: t('cancel'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}> ]}>
<Form stateOwner={this} format="wide"> <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="sendConfiguration" format="wide" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-with-send-permission-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />

View file

@ -255,11 +255,11 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
return <StaticField return <StaticField
id={prefix + 'grapesJSSourceType'} id={prefix + 'grapesJSSourceType'}
className={styles.formDisabled} className={styles.formDisabled}
label={t('type')}>{grapesJSSourceTypeLabels[owner.getFormValue(prefix + 'grapesJSSourceType')]}</StaticField>; label={t('Content')}>{grapesJSSourceTypeLabels[owner.getFormValue(prefix + 'grapesJSSourceType')]}</StaticField>;
} else { } else {
return <Dropdown return <Dropdown
id={prefix + 'grapesJSSourceType'} id={prefix + 'grapesJSSourceType'}
label={t('type')} label={t('Content')}
options={grapesJSSourceTypes}/>; options={grapesJSSourceTypes}/>;
} }
}, },

View file

@ -66,11 +66,13 @@ export default class CUD extends Component {
entity: PropTypes.object entity: PropTypes.object
} }
getFormValuesMutator(data) {
this.templateTypes[data.type].afterLoad(data);
}
componentDidMount() { componentDidMount() {
if (this.props.entity) { if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => { this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
this.templateTypes[data.type].afterLoad(data);
});
} else { } else {
const wizard = this.props.wizard; const wizard = this.props.wizard;
@ -114,15 +116,7 @@ export default class CUD extends Component {
validateNamespace(t, state); validateNamespace(t, state);
} }
async submitAndStay() { async submitHandler(submitAndLeave) {
await this.formHandleChangedError(async () => await this.doSubmit(true));
}
async submitAndLeave() {
await this.formHandleChangedError(async () => await this.doSubmit(false));
}
async doSubmit(stay) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -137,19 +131,25 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
this.templateTypes[data.type].beforeSave(data); this.templateTypes[data.type].beforeSave(data);
}); });
if (submitSuccessful) { if (submitResult) {
if (stay) { if (this.props.entity) {
await this.getFormValuesFromURL(`rest/mosaico-templates/${this.props.entity.id}`, data => { if (submitAndLeave) {
this.templateTypes[data.type].afterLoad(data); this.navigateToWithFlashMessage('/templates/mosaico', 'success', t('Mosaico template updated'));
});
this.enableForm();
this.setFormStatusMessage('success', t('mosaicoTemplateSaved'));
} else { } else {
this.navigateToWithFlashMessage('/templates/mosaico', 'success', t('mosaicoTemplateSaved')); await this.getFormValuesFromURL(`rest/mosaico-templates/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('Mosaico template updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/templates/mosaico', 'success', t('Mosaico template created'));
} else {
this.navigateToWithFlashMessage(`/templates/mosaico/${submitResult}/edit`, 'success', t('Mosaico template created'));
}
} }
} else { } else {
this.enableForm(); this.enableForm();
@ -183,7 +183,7 @@ export default class CUD extends Component {
<Title>{isEdit ? t('editMosaicoTemplate') : t('createMosaicoTemplate')}</Title> <Title>{isEdit ? t('editMosaicoTemplate') : t('createMosaicoTemplate')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('name')}/> <InputField id="name" label={t('name')}/>
<TextArea id="description" label={t('description')}/> <TextArea id="description" label={t('description')}/>
<Dropdown id="type" label={t('type')} options={this.typeOptions}/> <Dropdown id="type" label={t('type')} options={this.typeOptions}/>
@ -191,19 +191,11 @@ export default class CUD extends Component {
{form} {form}
{isEdit ?
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndStay')} onClickAsync={::this.submitAndStay}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')}/>
{canDelete &&
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/templates/mosaico/${this.props.entity.id}/delete`}/>
}
</ButtonRow>
:
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/templates/mosaico/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
}
</Form> </Form>
</div> </div>
); );

View file

@ -8,6 +8,7 @@ import Share from '../shares/Share';
import Files from "../lib/files"; import Files from "../lib/files";
import MosaicoCUD from './mosaico/CUD'; import MosaicoCUD from './mosaico/CUD';
import MosaicoList from './mosaico/List'; import MosaicoList from './mosaico/List';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
function getMenus(t) { function getMenus(t) {
@ -18,7 +19,7 @@ function getMenus(t) {
panelComponent: TemplatesList, panelComponent: TemplatesList,
children: { children: {
':templateId([0-9]+)': { ':templateId([0-9]+)': {
title: resolved => t('templateName', {name: resolved.template.name}), title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}),
resolve: { resolve: {
template: params => `rest/templates/${params.templateId}` template: params => `rest/templates/${params.templateId}`
}, },
@ -28,7 +29,7 @@ function getMenus(t) {
title: t('edit'), title: t('edit'),
link: params => `/templates/${params.templateId}/edit`, link: params => `/templates/${params.templateId}/edit`,
visible: resolved => resolved.template.permissions.includes('edit'), visible: resolved => resolved.template.permissions.includes('edit'),
panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} /> panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} setPanelInFullScreen={props.setPanelInFullScreen} />
}, },
files: { files: {
title: t('files'), title: t('files'),
@ -54,7 +55,7 @@ function getMenus(t) {
panelComponent: MosaicoList, panelComponent: MosaicoList,
children: { children: {
':mosaiceTemplateId([0-9]+)': { ':mosaiceTemplateId([0-9]+)': {
title: resolved => t('mosaicoTemplateName', {name: resolved.mosaicoTemplate.name}), title: resolved => t('mosaicoTemplateName', {name: ellipsizeBreadcrumbLabel(resolved.mosaicoTemplate.name)}),
resolve: { resolve: {
mosaicoTemplate: params => `rest/mosaico-templates/${params.mosaiceTemplateId}` mosaicoTemplate: params => `rest/mosaico-templates/${params.mosaiceTemplateId}`
}, },

View file

@ -62,12 +62,14 @@ export default class CUD extends Component {
entity: PropTypes.object entity: PropTypes.object
} }
componentDidMount() { getFormValuesMutator(data) {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
data.password = ''; data.password = '';
data.password2 = ''; data.password2 = '';
}); }
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
} else { } else {
this.populateFormValues({ this.populateFormValues({
username: '', username: '',
@ -156,7 +158,7 @@ export default class CUD extends Component {
validateNamespace(t, state); validateNamespace(t, state);
} }
async submitHandler() { async submitHandler(submitAndLeave) {
const t = this.props.t; const t = this.props.t;
let sendMethod, url; let sendMethod, url;
@ -172,12 +174,26 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('saving')); this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
delete data.password2; delete data.password2;
}); });
if (submitSuccessful) { if (submitResult) {
this.navigateToWithFlashMessage('/users', 'success', t('userSaved')); if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/users', 'success', t('User updated'));
} else {
await this.getFormValuesFromURL(`rest/users/${this.props.entity.id}`, ::this.getFormValuesMutator);
this.enableForm();
this.setFormStatusMessage('success', t('User updated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/users', 'success', t('User created'));
} else {
this.navigateToWithFlashMessage(`/users/${submitResult}/edit`, 'success', t('User created'));
}
}
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd')); this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
@ -248,7 +264,8 @@ export default class CUD extends Component {
<NamespaceSelect/> <NamespaceSelect/>
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/> <Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('deleteUser')} to={`/users/${this.props.entity.id}/delete`}/>} {canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('deleteUser')} to={`/users/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -8,6 +8,7 @@ import List
from './List'; from './List';
import UserShares import UserShares
from '../shares/UserShares'; from '../shares/UserShares';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
function getMenus(t) { function getMenus(t) {
return { return {
@ -17,7 +18,7 @@ function getMenus(t) {
panelComponent: List, panelComponent: List,
children: { children: {
':userId([0-9]+)': { ':userId([0-9]+)': {
title: resolved => t('userName-1', {name: resolved.user.name}), title: resolved => t('userName-1', {name: ellipsizeBreadcrumbLabel(resolved.user.name)}),
resolve: { resolve: {
user: params => `rest/users/${params.userId}` user: params => `rest/users/${params.userId}`
}, },

41
docker-compose-local.yml Normal file
View file

@ -0,0 +1,41 @@
version: '3'
services:
mysql:
image: mariadb:10.4
environment:
- MYSQL_ROOT_PASSWORD=mailtrain
- MYSQL_DATABASE=mailtrain
- MYSQL_USER=mailtrain
- MYSQL_PASSWORD=mailtrain
volumes:
- mysql-data:/var/lib/mysql
redis:
image: redis:5
volumes:
- redis-data:/data
mongo:
image: mongo:4-xenial
volumes:
- mongo-data:/data/db
mailtrain:
build: .
command: ${MAILTRAIN_SETTINGS}
ports:
- "3000:3000"
- "3003:3003"
- "3004:3004"
volumes:
- mailtrain-files:/app/server/files
- mailtrain-reports:/app/protected/reports
volumes:
mysql-data:
redis-data:
mongo-data:
mailtrain-files:
mailtrain-reports:

View file

@ -22,7 +22,7 @@ services:
- mongo-data:/data/db - mongo-data:/data/db
mailtrain: mailtrain:
build: . image: mailtrain/mailtrain:latest
command: ${MAILTRAIN_SETTINGS} command: ${MAILTRAIN_SETTINGS}
ports: ports:
- "3000:3000" - "3000:3000"

View file

@ -10,11 +10,11 @@
"thisApiCallEitherInsertsANewSubscription": "This API call either inserts a new subscription or updates existing. Fields not included are left as is, so if you update only LAST_NAME value, then FIRST_NAME is kept untouched for an existing subscription.", "thisApiCallEitherInsertsANewSubscription": "This API call either inserts a new subscription or updates existing. Fields not included are left as is, so if you update only LAST_NAME value, then FIRST_NAME is kept untouched for an existing subscription.",
"arguments": "arguments", "arguments": "arguments",
"yourPersonalAccessToken": "your personal access token", "yourPersonalAccessToken": "your personal access token",
"subscribersEmailAddress": "subscriber\\'s email address", "subscribersEmailAddress": "subscriber's email address",
"required": "required", "required": "required",
"subscribersFirstName": "subscriber\\'s first name", "subscribersFirstName": "subscriber's first name",
"subscribersLastName": "subscriber\\'s last name", "subscribersLastName": "subscriber's last name",
"subscribersTimezoneEgEuropeTallinnPstOr": "subscriber\\'s timezone (eg. \"Europe/Tallinn\", \"PST\" or \"UTC\"). If not set defaults to \"UTC\"", "subscribersTimezoneEgEuropeTallinnPstOr": "subscriber's timezone (eg. \"Europe/Tallinn\", \"PST\" or \"UTC\"). If not set defaults to \"UTC\"",
"customFieldValueUseYesnoForOptionGroup": "custom field value. Use yes/no for option group values (checkboxes, radios, drop downs)", "customFieldValueUseYesnoForOptionGroup": "custom field value. Use yes/no for option group values (checkboxes, radios, drop downs)",
"additionalPostArguments": "Additional POST arguments", "additionalPostArguments": "Additional POST arguments",
"setToYesIfYouWantToMakeSureTheEmailIs": "set to \"yes\" if you want to make sure the email is marked as subscribed even if it was previously marked as unsubscribed. If the email was already unsubscribed/blocked then subscription status is not changed", "setToYesIfYouWantToMakeSureTheEmailIs": "set to \"yes\" if you want to make sure the email is marked as subscribed even if it was previously marked as unsubscribed. If the email was already unsubscribed/blocked then subscription status is not changed",
@ -28,7 +28,7 @@
"thisApiCallCreatesANewCustomFieldForA": "This API call creates a new custom field for a list.", "thisApiCallCreatesANewCustomFieldForA": "This API call creates a new custom field for a list.",
"fieldName": "field name", "fieldName": "field name",
"oneOfTheFollowingTypes": "one of the following types:", "oneOfTheFollowingTypes": "one of the following types:",
"ifTheTypeIsOptionThenYouAlsoNeedTo": "If the type is \\'option\\' then you also need to specify the parent element ID", "ifTheTypeIsOptionThenYouAlsoNeedTo": "If the type is 'option' then you also need to specify the parent element ID",
"templateForTheGroupElementIfNotSetThen": "Template for the group element. If not set, then values of the elements are joined with commas", "templateForTheGroupElementIfNotSetThen": "Template for the group element. If not set, then values of the elements are joined with commas",
"ifNotVisibleThenTheSubscriberCanNotView": "if not visible then the subscriber can not view or modify this value at the profile page", "ifNotVisibleThenTheSubscriberCanNotView": "if not visible then the subscriber can not view or modify this value at the profile page",
"getListOfBlacklistedEmails": "Get list of blacklisted emails", "getListOfBlacklistedEmails": "Get list of blacklisted emails",
@ -117,6 +117,8 @@
"addToBlacklist": "Add to Blacklist", "addToBlacklist": "Add to Blacklist",
"blacklistedEmails": "Blacklisted Emails", "blacklistedEmails": "Blacklisted Emails",
"createRegularCampaign": "Create Regular Campaign", "createRegularCampaign": "Create Regular Campaign",
"sendSettings": "Send settings",
"workWithCampaignNamespace": "Work with campaign's namespace",
"createRssCampaign": "Create RSS Campaign", "createRssCampaign": "Create RSS Campaign",
"createTriggeredCampaign": "Create Triggered Campaign", "createTriggeredCampaign": "Create Triggered Campaign",
"editRegularCampaign": "Edit Regular Campaign", "editRegularCampaign": "Edit Regular Campaign",
@ -146,6 +148,7 @@
"description": "Description", "description": "Description",
"namespace": "Namespace", "namespace": "Namespace",
"namespace_plural": "Namespaces", "namespace_plural": "Namespaces",
"namespaceFiltering": "Namespace filtering",
"remove": "Remove", "remove": "Remove",
"insertNewEntryBeforeThisOne": "Insert new entry before this one", "insertNewEntryBeforeThisOne": "Insert new entry before this one",
"moveUp": "Move up", "moveUp": "Move up",
@ -198,6 +201,7 @@
"triggers": "Triggers", "triggers": "Triggers",
"share": "Share", "share": "Share",
"createCampaign": "Create Campaign", "createCampaign": "Create Campaign",
"namespaceFilter": "Namespace filtering",
"regular": "Regular", "regular": "Regular",
"rss": "RSS", "rss": "RSS",
"triggered": "Triggered", "triggered": "Triggered",
@ -244,7 +248,9 @@
"timeMustNotBeEmpty": "Time must not be empty", "timeMustNotBeEmpty": "Time must not be empty",
"timeIsInvalid": "Time is invalid", "timeIsInvalid": "Time is invalid",
"confirmReset": "Confirm reset", "confirmReset": "Confirm reset",
"confirmLaunch": "Confirm launch",
"doYouWantToResetTheCampaign?All": "Do you want to reset the campaign? All statistics and the track of delivered messages will be lost.", "doYouWantToResetTheCampaign?All": "Do you want to reset the campaign? All statistics and the track of delivered messages will be lost.",
"doYouWantToLaunchTheCampaign?All": "Do you want to launch the campaign?",
"no": "No", "no": "No",
"yes": "Yes", "yes": "Yes",
"subscribers-1": "subscribers", "subscribers-1": "subscribers",
@ -332,7 +338,7 @@
"size": "Size", "size": "Size",
"download": "Download", "download": "Download",
"confirmFileDeletion": "Confirm file deletion", "confirmFileDeletion": "Confirm file deletion",
"filesareYouSureToDeleteFile": "files:areYouSureToDeleteFile", "filesareYouSureToDeleteFile": "Are you sure you want to delete the file?",
"dropCountFile": "Drop {{count}} file", "dropCountFile": "Drop {{count}} file",
"dropCountFile_plural": "Drop {{count}} files", "dropCountFile_plural": "Drop {{count}} files",
"dropFilesHere": "Drop files here", "dropFilesHere": "Drop files here",
@ -353,11 +359,11 @@
"andMore": "... and more", "andMore": "... and more",
"confirmDeletion": "Confirm Deletion", "confirmDeletion": "Confirm Deletion",
"areYouSureYouWantToDeleteName?": "Are you sure you want to delete \"{{name}}\"?", "areYouSureYouWantToDeleteName?": "Are you sure you want to delete \"{{name}}\"?",
"namespacemustBeSelected": "namespace.mustBeSelected", "namespacemustBeSelected": "Namespace must be selected",
"mjml": "MJML", "mjml": "MJML",
"html": "HTML", "html": "HTML",
"countEntriesSelected": "{{ count }} entries selected.", "countEntriesSelected": "{{ count }} entries selected.",
"loading-1": "Loading...", "loading-1": "Loading ...",
"customFormMustBeSelected": "Custom form must be selected", "customFormMustBeSelected": "Custom form must be selected",
"listSaved": "List saved", "listSaved": "List saved",
"onestepIeNoEmailWithConfirmationLink": "One-step (i.e. no email with confirmation link)", "onestepIeNoEmailWithConfirmationLink": "One-step (i.e. no email with confirmation link)",
@ -485,7 +491,7 @@
"formsPreview": "Forms Preview", "formsPreview": "Forms Preview",
"listToPreviewOn": "List To Preview On", "listToPreviewOn": "List To Preview On",
"selectListWhoseFieldsWillBeUsedToPreview": "Select list whose fields will be used to preview the forms.", "selectListWhoseFieldsWillBeUsedToPreview": "Select list whose fields will be used to preview the forms.",
"noteTheseLinksAreSolelyForAQuickPreview": "Note: These links are solely for a quick preview. To get the address of the subscription form, go to the list\\'s subscribers and click on \"Subscription Form\".", "noteTheseLinksAreSolelyForAQuickPreview": "Note: These links are solely for a quick preview. To get the address of the subscription form, go to the list's subscribers and click on \"Subscription Form\".",
"formPreview": "Form preview:", "formPreview": "Form preview:",
"templates": "Templates", "templates": "Templates",
"customFormsUseMjmlForFormattingSeeThe": "Custom forms use MJML for formatting. See the MJML documentation <1>here</1>", "customFormsUseMjmlForFormattingSeeThe": "Custom forms use MJML for formatting. See the MJML documentation <1>here</1>",
@ -768,7 +774,7 @@
"theVerpServerHostnameEgBouncesexamplecom": "The VERP server hostname, eg. bounces.example.com", "theVerpServerHostnameEgBouncesexamplecom": "The VERP server hostname, eg. bounces.example.com",
"verpBounceHandlingServerHostnameThis": "VERP bounce handling server hostname. This hostname is used in the SMTP envelope FROM address and the MX DNS records should point to this server", "verpBounceHandlingServerHostnameThis": "VERP bounce handling server hostname. This hostname is used in the SMTP envelope FROM address and the MX DNS records should point to this server",
"disableSenderHeader": "Disable sender header", "disableSenderHeader": "Disable sender header",
"withDmarcTheReturnPathAndFromAddressMust": "With DMARC, the Return-Path and From address must match the same domain. By default we get around this by using the VERP address in the Sender header, with the side effect that some email clients diplay an ugly on behalf of message. You can safely disable this Sender header if you\\'re not using DMARC or your VERP hostname is in the same domain as the From address.", "withDmarcTheReturnPathAndFromAddressMust": "With DMARC, the Return-Path and From address must match the same domain. By default we get around this by using the VERP address in the Sender header, with the side effect that some email clients diplay an ugly on behalf of message. You can safely disable this Sender header if you're not using DMARC or your VERP hostname is in the same domain as the From address.",
"mailtrainIsAbleToUseVerpBasedRoutingTo": "<0>Mailtrain is able to use VERP based routing to detect bounces. In this case the message is sent to the recipient using a custom VERP address as the return path of the message. If the message is not accepted a bounce email is sent to this special VERP address and thus a bounce is detected.</0>", "mailtrainIsAbleToUseVerpBasedRoutingTo": "<0>Mailtrain is able to use VERP based routing to detect bounces. In this case the message is sent to the recipient using a custom VERP address as the return path of the message. If the message is not accepted a bounce email is sent to this special VERP address and thus a bounce is detected.</0>",
"toGetVerpWorkingYouNeedToSetUpADnsMx": "<0>To get VERP working you need to set up a DNS MX record that points to your Mailtrain hostname. You must also ensure that Mailtrain VERP interface is available from port 25 of your server (port 25 usually requires root user privileges). This way if anyone tries to send email to someuser@verp-hostname then the email should end up to this server.</0>", "toGetVerpWorkingYouNeedToSetUpADnsMx": "<0>To get VERP working you need to set up a DNS MX record that points to your Mailtrain hostname. You must also ensure that Mailtrain VERP interface is available from port 25 of your server (port 25 usually requires root user privileges). This way if anyone tries to send email to someuser@verp-hostname then the email should end up to this server.</0>",
"verpUsuallyOnlyWorksIfYouAreUsingYourOwn": "<0>VERP usually only works if you are using your own SMTP server. Regular relay services (SES, SparkPost, Gmail etc.) tend to remove the VERP address from the message.</0>", "verpUsuallyOnlyWorksIfYouAreUsingYourOwn": "<0>VERP usually only works if you are using your own SMTP server. Regular relay services (SES, SparkPost, Gmail etc.) tend to remove the VERP address from the message.</0>",
@ -786,8 +792,8 @@
"uswest2": "US-WEST-2", "uswest2": "US-WEST-2",
"euwest1": "EU-WEST-1", "euwest1": "EU-WEST-1",
"builtinZoneMta": "Built-in ZoneMTA", "builtinZoneMta": "Built-in ZoneMTA",
"dynamicConfigurationOfDkimKeysViaZoneMt": "Dynamic configuration of DKIM keys via ZoneMTA\\'s Mailtrain plugin (use this option for builtin ZoneMTA)", "dynamicConfigurationOfDkimKeysViaZoneMt": "Dynamic configuration of DKIM keys via ZoneMTA's Mailtrain plugin",
"dynamicConfigurationOfDkimKeysViaZoneMt-1": "Dynamic configuration of DKIM keys via ZoneMTA\\'s HTTP config plugin", "dynamicConfigurationOfDkimKeysViaZoneMt-1": "Dynamic configuration of DKIM keys via ZoneMTA's HTTP config plugin",
"noDynamicConfigurationOfDkimKeys": "No dynamic configuration of DKIM keys", "noDynamicConfigurationOfDkimKeys": "No dynamic configuration of DKIM keys",
"mailerSettings": "Mailer Settings", "mailerSettings": "Mailer Settings",
"mailerType": "Mailer type", "mailerType": "Mailer type",
@ -845,7 +851,7 @@
"passphraseForTheKeyIfSet": "Passphrase for the key if set", "passphraseForTheKeyIfSet": "Passphrase for the key if set",
"onlyFillThisIfYourPrivateKeyIsEncrypted": "Only fill this if your private key is encrypted with a passphrase", "onlyFillThisIfYourPrivateKeyIsEncrypted": "Only fill this if your private key is encrypted with a passphrase",
"gpgPrivateKey": "GPG private key", "gpgPrivateKey": "GPG private key",
"beginsWithBeginPgpPrivateKeyBlock": "Begins with \\'-----BEGIN PGP PRIVATE KEY BLOCK-----\\'", "beginsWithBeginPgpPrivateKeyBlock": "Begins with '-----BEGIN PGP PRIVATE KEY BLOCK-----'",
"thisValueIsOptionalIfYouDoNotProvideA": "This value is optional. If you do not provide a private key GPG encrypted messages are sent without signing.", "thisValueIsOptionalIfYouDoNotProvideA": "This value is optional. If you do not provide a private key GPG encrypted messages are sent without signing.",
"onlyMessagesThatAreEncryptedCanBeSigned": "<0>Only messages that are encrypted can be signed. Subsribers who have not set up a GPG public key in their profile receive normal email messages. Users with GPG key set receive encrypted messages and if you have signing key also set, the messages are signed with this key.</0>", "onlyMessagesThatAreEncryptedCanBeSigned": "<0>Only messages that are encrypted can be signed. Subsribers who have not set up a GPG public key in their profile receive normal email messages. Users with GPG key set receive encrypted messages and if you have signing key also set, the messages are signed with this key.</0>",
"doNotUseSensitiveKeysHereThePrivateKey": "<0>Do not use sensitive keys here. The private key and passphrase are not encrypted in the database.</0>", "doNotUseSensitiveKeysHereThePrivateKey": "<0>Do not use sensitive keys here. The private key and passphrase are not encrypted in the database.</0>",
@ -920,7 +926,7 @@
"deleteUser": "Delete User", "deleteUser": "Delete User",
"userName-1": "User \"{{name}}\"", "userName-1": "User \"{{name}}\"",
"shares": "Shares", "shares": "Shares",
"subscriptionconfirmed": "subscription.confirmed", "subscriptionconfirmed": "Subscription Confirmed",
"listEmailAddressAlreadyRegistered": "{{list}}: Email Address Already Registered", "listEmailAddressAlreadyRegistered": "{{list}}: Email Address Already Registered",
"listPleaseConfirmEmailChangeIn": "{{list}}: Please Confirm Email Change in Subscription", "listPleaseConfirmEmailChangeIn": "{{list}}: Please Confirm Email Change in Subscription",
"pleaseConfirmSubscription": "Please Confirm Subscription", "pleaseConfirmSubscription": "Please Confirm Subscription",
@ -929,7 +935,7 @@
"invalidEmailAddressEmailMxRecordNotFound": "Invalid email address \"{{email}}\": MX record not found for domain", "invalidEmailAddressEmailMxRecordNotFound": "Invalid email address \"{{email}}\": MX record not found for domain",
"invalidEmailAddressEmailAddressDomainNot": "Invalid email address \"{{email}}\": Address domain not found", "invalidEmailAddressEmailAddressDomainNot": "Invalid email address \"{{email}}\": Address domain not found",
"invalidEmailAddressEmailAddressDomain": "Invalid email address \"{{email}}\": Address domain name is required", "invalidEmailAddressEmailAddressDomain": "Invalid email address \"{{email}}\": Address domain name is required",
"invalidEmailGeneric": "invalidEmailGeneric", "invalidEmailGeneric": "Invalid email address \"{{email}}\"",
"mailerPasswordChangeRequest": "Mailer password change request", "mailerPasswordChangeRequest": "Mailer password change request",
"mailtrain": "Mailtrain", "mailtrain": "Mailtrain",
"emailAddressChanged": "Email address changed", "emailAddressChanged": "Email address changed",

969
locales/es-ES/common.json Normal file
View file

@ -0,0 +1,969 @@
{
"welcomeToMailtrain": "Bienvenido a Mailtrain...",
"personalAccessToken": "Token de acceso personal",
"accessTokenNotYetGenerated": "El token de acceso aun no se ha generado",
"api": "API",
"resetAccessToken": "Reiniciar token de acceso",
"generateAccessToken": "Generar token de acceso",
"notesAboutTheApi": "Notas sobre la API",
"addSubscription": "Añadir suscripcion",
"thisApiCallEitherInsertsANewSubscription": "Esta llamada a la API inserta una nueva suscripción o actualiza las existentes. Los campos no incluidos se dejan como están, por lo que si actualiza solo el valor de LAST_NAME, FIRST_NAME se mantiene intacto para una suscripción existente.",
"arguments": "argumentos",
"yourPersonalAccessToken": "tu token de acceso personal",
"subscribersEmailAddress": "email del suscriptor",
"required": "requerido",
"subscribersFirstName": "nombre del suscriptor",
"subscribersLastName": "apellidos del suscriptor",
"subscribersTimezoneEgEuropeTallinnPstOr": "zona horaria del suscriptor (eg. \"Europe/Tallinn\", \"PST\" or \"UTC\"). Si no, asigna por defecto a \"UTC\"",
"customFieldValueUseYesnoForOptionGroup": "valor de campo personalizado. Usa si/no para opciones grupales (checkboxes, radios, drop downs)",
"additionalPostArguments": "argumentos POST adicionales",
"setToYesIfYouWantToMakeSureTheEmailIs": "set to \"yes\" if you want to make sure the email is marked as subscribed even if it was previously marked as unsubscribed. If the email was already unsubscribed/blocked then subscription status is not changed",
"setToYesIfYouWantToSendConfirmationEmail": "set to \"yes\" if you want to send confirmation email to the subscriber before actually marking as subscribed",
"example": "Ejemplo",
"removeSubscription": "Eliminar suscripción",
"thisApiCallMarksASubscriptionAs": "Esta llamada a la API marca una suscricion como desuscrita",
"deleteSubscription": "Borrar suscripción",
"thisApiCallDeletesASubscription": "Esta llamada a la API borra una suscripción",
"addNewCustomField": "Añadir un campo personalizado",
"thisApiCallCreatesANewCustomFieldForA": "Esta llamada a la API crea un nuevo campo personalizado para una lista.",
"fieldName": "Nombre del campo",
"oneOfTheFollowingTypes": "uno de los siguientes tipos:",
"ifTheTypeIsOptionThenYouAlsoNeedTo": "Si el tipo es 'opción' entonces también necesitas especificar el ID del elemento padre",
"templateForTheGroupElementIfNotSetThen": "Template for the group element. If not set, then values of the elements are joined with commas",
"ifNotVisibleThenTheSubscriberCanNotView": "if not visible then the subscriber can not view or modify this value at the profile page",
"getListOfBlacklistedEmails": "Get list of blacklisted emails",
"thisApiCallGetListOfBlacklistedEmails": "This API call get list of blacklisted emails.",
"startPosition": "Empezar posición",
"optionalDefault0": "optional, default 0",
"limitEmailsCountInResponse": "limit emails count in response",
"optionalDefault10000": "opcional, por defecto 10000",
"filterByPartOfEmail": "filter by part of email",
"optionalDefault": "opcional, por defecto \"\"",
"addEmailToBlacklist": "Add email to blacklist",
"thisApiCallEitherAddEmailsToBlacklist": "This API call either add emails to blacklist",
"emailAddress": "dirección de correo",
"deleteEmailFromBlacklist": "Borrar email de la lista negra",
"thisApiCallEitherDeleteEmailsFrom": "This API call either delete emails from blacklist",
"getTheListsAUserHasSubscribedTo": "Get the lists a user has subscribed to",
"retrieveTheListsThatTheUserWithEmailHas": "Retrieve the lists that the user with :email has subscribed to.",
"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": "El email no debe estar vacío.",
"invalidEmailAddress": "Email inválido.",
"theEmailIsAlreadyAssociatedWithAnother": "El email ya está asociado a otro usuario del sistema.",
"validationIsInProgress": "Validación en progreso...",
"fullNameMustNotBeEmpty": "El nombre completo no debe estar vacío.",
"currentPasswordMustNotBeEmpty": "La contraseña actual no debe estar vacía.",
"incorrectPassword": "Contraseña incorrecta.",
"passwordsMustMatch": "Las contraseñas deben coincidir",
"updatingUserProfile": "Actualizando perfil del usuario ...",
"userProfileUpdated": "Perfil del usuario actualizado",
"thereAreErrorsInTheFormPleaseFixThemAnd": "There are errors in the form. Please fix them and submit again.",
"yourUpdatesCannotBeSaved": "Your updates cannot be saved.",
"thePasswordIsIncorrectPossiblyJust": "The password is incorrect (possibly just changed in another window / session). Enter correct password and try again.",
"theEmailIsAlreadyAssignedToAnotherUser": "The email is already assigned to another user. Enter another email and try again.",
"account": "Account",
"generalSettings": "General Settings",
"fullName": "Full Name",
"email": "Email",
"thisAddressIsUsedForAccountRecoveryIn": "This address is used for account recovery in case you loose your password",
"passwordChange": "Password Change",
"youOnlyNeedToFillOutThisFormIfYouWantTo": "You only need to fill out this form if you want to change your current password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"update": "Update",
"accountManagementIsNotPossibleBecause": "Account management is not possible because Mailtrain is configured to use externally managed users.",
"ifYouWantToChangeThePasswordUseThisLink": "If you want to change the password, use <1>this link</1>.",
"usernameOrEmailMustNotBeEmpty": "Username or email must not be empty",
"processing": "Processing ...",
"ifTheUsernameEmailExistsInTheSystem": "If the username / email exists in the system, password reset link will be sent to the registered email.",
"pleaseEnterYourUsernameEmailAndTryAgain": "Please enter your username / email and try again.",
"passwordReset": "Password Reset",
"pleaseProvideTheUsernameOrEmailAddress": "Please provide the username or email address that is registered with your Mailtrain account.",
"weWillSendYouAnEmailThatWillAllowYouTo": "We will send you an email that will allow you to reset your password.",
"usernameOrEmail": "Username or email",
"sendEmail": "Send email",
"userNameMustNotBeEmpty": "User name must not be empty",
"passwordMustNotBeEmpty": "Password must not be empty",
"verifyingCredentials": "Verifying credentials ...",
"pleaseEnterYourCredentialsAndTryAgain": "Please enter your credentials and try again.",
"invalidUsernameOrPassword": "Invalid username or password.",
"forgotYourPassword?": "Olvidaste tu contraseña?",
"signIn": "Iniciar sesión",
"username": "Usuario",
"password": "Contraseña",
"rememberMe": "Recuérdame",
"resettingPassword": "Resetting password ...",
"passwordReset-1": "Password reset",
"yourPasswordCannotBeReset": "Your password cannot be reset.",
"thePasswordResetTokenHasExpired": "The password reset token has expired.",
"clickHereToRequestANewPasswordResetLink": "Click here to request a new password reset link.",
"validatingPasswordResetToken": "Validating password reset token ...",
"thePasswordCannotBeReset": "The password cannot be reset",
"setNewPasswordFor": "Set new password for",
"resetPassword": "Reset password",
"emailMustNotBeEmpty-1": "Email must not be empty",
"theEmailIsAlreadyOnBlacklist": "The email is already on blacklist.",
"saving": "Saving ...",
"thereAreErrorsInTheFormPleaseFixThemAnd-1": "There are errors in the form. Please fix them and try again.",
"removeFromBlacklist": "Remove from blacklist",
"confirmRemovalFromBlacklist": "Confirm Removal From Blacklist",
"areYouSureYouWantToRemoveEmailFromThe": "Are you sure you want to remove {{email}} from the blacklist?",
"removingEmailFromTheBlacklist": "Removing {{email}} from the blacklist",
"emailRemovedFromTheBlacklist": "{{email}} removed from the blacklist",
"blacklist": "Blacklist",
"addEmailToBlacklist-1": "Add Email to Blacklist",
"addToBlacklist": "Add to Blacklist",
"blacklistedEmails": "Blacklisted Emails",
"createRegularCampaign": "Crear Campaña Regular",
"sendSettings": "Configuración de envío",
"workWithCampaignNamespace": "Trabajar con el espacio de nombres de la campaña",
"createRssCampaign": "Create RSS Campaign",
"createTriggeredCampaign": "Create Triggered Campaign",
"editRegularCampaign": "Edit Regular Campaign",
"editRssCampaign": "Edit RSS Campaign",
"editTriggeredCampaign": "Edit Triggered Campaign",
"template": "Template",
"template_plural": "Templates",
"customContentClonedFromTemplate": "Custom content cloned from template",
"customContentClonedFromAnotherCampaign": "Custom content cloned from another campaign",
"customContent": "Custom content",
"url": "URL",
"nameMustNotBeEmpty": "Name must not be empty",
"sendConfigurationMustBeSelected": "Send configuration must be selected",
"fromEmailMustNotBeEmpty": "\"From\" email must not be empty",
"templateMustBeSelected": "Template must be selected",
"campaignMustBeSelected": "Campaign must be selected",
"typeMustBeSelected": "Type must be selected",
"urlMustNotBeEmpty": "URL must not be empty",
"rssFeedUrlMustBeGiven": "RSS feed URL must be given",
"listMustBeSelected": "List must be selected",
"segmentMustBeSelected": "Segment must be selected",
"campaignSaved": "Campaign saved",
"rssFeedUrl": "RSS Feed Url",
"name": "Nombre",
"id": "ID",
"subscribers": "Suscriptores",
"description": "Descripciçon",
"namespace": "Espacio de nombres",
"namespace_plural": "Espacios de nombres",
"namespaceFiltering": "Filtrar espacio de nombres",
"remove": "Eliminar",
"insertNewEntryBeforeThisOne": "Insert new entry before this one",
"moveUp": "Move up",
"moveDown": "Move down",
"list": "List",
"list_plural": "Lists",
"segment": "Segment",
"useAParticularSegment": "Use a particular segment",
"lists": "Listas",
"addList": "Add list",
"type": "Type",
"created": "Created",
"override": "Override",
"fromName": "\"From\" name",
"fromEmailAddress": "\"From\" email address",
"replytoEmailAddress": "\"Reply-to\" email address",
"subjectLine": "\"Subject\" line",
"loadingSendConfiguration": "Loading send configuration ...",
"contentSource": "Content source",
"selectingATemplateCreatesACampaign": "Selecting a template creates a campaign specific copy from it.",
"campaign": "Campaña",
"campaign_plural": "Campañas",
"contentOfTheSelectedCampaignWillBeCopied": "Content of the selected campaign will be copied into this campaign.",
"renderUrl": "Render URL",
"ifAMessageIsSentThenThisUrlWillBePosTed": "If a message is sent then this URL will be POSTed to using Merge Tags as POST body. Use this if you want to generate the HTML message yourself.",
"save": "Save",
"saveAndEditContent": "Save and edit content",
"saveCampaignAndGoToStatus": "Save campaign and go to status",
"deletingCampaign": "Deleting campaign ...",
"campaignDeleted": "Campaign deleted",
"formCannotBeEditedBecauseTheCampaignIs": "Form cannot be edited because the campaign is currently being sent out. Wait till the sending is finished and refresh.",
"thisIsTheCampaignIdDisplayedToThe": "This is the campaign ID displayed to the subscribers",
"sendConfiguration": "Send configuration",
"sendConfiguration_plural": "Send configurations",
"customUnsubscribeUrl": "Custom unsubscribe URL",
"disableOpenedTracking": "Disable opened tracking",
"disableClickedTracking": "Disable clicked tracking",
"delete": "Delete",
"editCustomContent": "Edit Custom Content",
"customTemplateEditor": "Custom template editor",
"testSend": "Test send",
"status": "Status",
"sendingScheduled": "Sending scheduled",
"sending": "Sending",
"statistics": "Statistics",
"edit": "Edit",
"content": "Content",
"files": "Files",
"attachments": "Attachments",
"triggers": "Triggers",
"share": "Share",
"createCampaign": "Create Campaign",
"namespaceFilter": "Namespace filtering",
"regular": "Regular",
"rss": "RSS",
"triggered": "Triggered",
"campaigns": "Campañas",
"campaignStatistics": "Campaign Statistics",
"total": "Total",
"delivered": "Delivered",
"blacklisted": "Blacklisted",
"bounced": "Bounced",
"complaints": "Complaints",
"unsubscribed": "Unsubscribed",
"opened": "Opened",
"clicked": "Clicked",
"uniqueVisitors": "Unique visitors",
"totalClicks": "Total clicks",
"campaignLinks": "Campaign links",
"subscriptionId": "Subscription ID",
"listId": "List ID",
"listNamespace": "List namespace",
"opensCount": "Opens count",
"countries": "Countries",
"devices": "Devices",
"desktop": "Desktop",
"tv": "TV",
"tablet": "Tablet",
"phone": "Phone",
"bot": "Bot",
"car": "Car",
"console": "Console",
"distributionByDeviceType": "Distribution by device type",
"loadingChart": "Loading chart",
"deviceType": "Tipo de dispositivo",
"count": "Cuenta",
"unknown": "Desconocido",
"distributionByCountry": "Distribucion por pais",
"country": "País",
"detailedStatistics": "Estadísticas detalladas",
"listOfSubscribersThatOpenedTheCampaign": "List de suscriptores que han abierto la campaña",
"subscriptionHasToBeSelectedToShowThe": "La suscripción debe ser seleccionada para mostrar la campaña a un usuario.",
"previewCampaignAs": "Prevista de campaña como",
"preview": "Prevista",
"dateMustNotBeEmpty": "La fecha no debe estar vacía",
"dateIsInvalid": "La fecha es inválida",
"timeMustNotBeEmpty": "El tiempo no debe estar vacío",
"timeIsInvalid": "El tiempo es inválido",
"confirmReset": "Confirmar reinicio",
"confirmLaunch": "Confirm launzamiento",
"doYouWantToResetTheCampaign?All": "Quieres reiniciar la campaña? Todas las estadísticas se perderán.",
"doYouWantToLaunchTheCampaign?All": "Quieres lanzar la campaña?",
"no": "No",
"yes": "Si",
"subscribers-1": "subscribers",
"sendStatus": "Send status",
"campaignIsScheduledForDelivery": "Campaign is scheduled for delivery.",
"campaignIsReadyToBeSentOut": "Campaign is ready to be sent out.",
"sendLater": "Send later",
"scheduleDeliveryAtAParticularDatetime": "Schedule delivery at a particular date/time",
"date": "Date",
"time": "Time",
"enter24hourTimeInFormatHhmmEg1348": "Enter 24-hour time in format HH:MM (e.g. 13:48)",
"rescheduleSend": "Reschedule send",
"scheduleSend": "Schedule send",
"send": "Send",
"viewStatistics": "View statistics",
"campaignIsBeingSentOut": "Campaign is being sent out.",
"stop": "Stop",
"allMessagesSent!HitContinueIfYouYouWant": "All messages sent! Hit \"Continue\" if you you want to send this campaign to new subscribers.",
"continue": "Continue",
"reset": "Reset",
"yourCampaignIsCurrentlyDisabledClick": "Your campaign is currently disabled. Click Enable button to start enable it.",
"enable": "Enable",
"yourCampaignIsEnabledAndSendingMessages": "Your campaign is enabled and sending messages.",
"disable": "Disable",
"campaignStatus": "Campaign Status",
"targetListssegments": "Target lists/segments",
"ifANewEntryIsFoundFromCampaignFeedANew": "If a new entry is found from campaign feed a new subcampaign is created of that entry and it will be listed here",
"sendingTestEmail": "Sending test email",
"subscriptionHasToBeSelected": "Subscription has to be selected.",
"sendTestEmail": "Send Test Email",
"cancel": "Cancel",
"subscription": "Subscription",
"idle": "Idle",
"scheduled": "Scheduled",
"paused": "Paused",
"finished": "Finished",
"inactive": "Inactive",
"active": "Active",
"campaignName": "Campaign \"{{name}}\"",
"deliveredEmails": "Delivered Emails",
"complained": "Complained",
"subscribersThatComplained": "Subscribers that Complained",
"emailsThatBounced": "Emails that Bounced",
"subscribersThatUnsubscribed": "Subscribers that Unsubscribed",
"clicks": "Clicks",
"theseFilesArePubliclyAvailableViaHttpSo": "These files are publicly available via HTTP so that they can be linked to from the content of the campaign.",
"theseFilesWillBeAttachedToTheCampaign": "These files will be attached to the campaign emails as proper attachments. This means they count towards to the eventual size of the email.",
"triggerName": "Trigger \"{{name}}\"",
"create": "Create",
"valuesMustNotBeEmpty": "Values must not be empty",
"valueMustBeANonnegativeNumber": "Value must be a non-negative number",
"sourceCampaignMustNotBeEmpty": "Source campaign must not be empty",
"triggerSaved": "Trigger saved",
"deletingTrigger": "Deleting trigger ...",
"triggerDeleted": "Trigger deleted",
"editTrigger": "Edit Trigger",
"createTrigger": "Create Trigger",
"entity": "Entity",
"selectTheTypeOfTheTriggerRule": "Select the type of the trigger rule.",
"triggerFires": "Trigger fires",
"event": "Event",
"selectTheEventThatTriggersSendingThe": "Select the event that triggers sending the campaign.",
"enabled": "Enabled",
"daysAfter": "Days after",
"latestOpen": "Latest open",
"latestClick": "Latest click",
"notOpened": "Not opened",
"notClicked": "Not clicked",
"close": "Close",
"countFileAdded": "{{count}} file added",
"countFileAdded_plural": "{{count}} files added",
"countFileReplaced": "{{count}} file replaced",
"countFileReplaced_plural": "{{count}} files replaced",
"countFileIgnored": "{{count}} file ignored",
"countFileIgnored_plural": "{{count}} files ignored",
"countFileUploaded": "{{count}} file uploaded",
"countFileUploaded_plural": "{{count}} files uploaded",
"uploadingCountFile": "Uploading {{count}} file",
"uploadingCountFile_plural": "Uploading {{count}} files",
"fileUploadFailed": "File upload failed:",
"noFilesToUpload": "No files to upload",
"deletingFile": "Deleting file ...",
"fileDeleted": "File deleted",
"deleteFileFailed": "Delete file failed:",
"size": "Size",
"download": "Download",
"confirmFileDeletion": "Confirm file deletion",
"filesareYouSureToDeleteFile": "Are you sure you want to delete the file?",
"dropCountFile": "Drop {{count}} file",
"dropCountFile_plural": "Drop {{count}} files",
"dropFilesHere": "Drop files here",
"loading": "Loading ...",
"openCalendar": "Open calendar",
"select": "Select",
"someoneElseHasIntroducedModificationIn": "Someone else has introduced modification in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.",
"itSeemsThatSomeoneElseHasDeletedThe": "It seems that someone else has deleted the target namespace in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.",
"itSeemsThatSomeoneElseHasDeletedThe-1": "It seems that someone else has deleted the entity in the meantime.",
"customForms": "Custom forms",
"report": "Report",
"report_plural": "Reports",
"reportTemplate": "Report template",
"reportTemplate_plural": "Report templates",
"mosaicoTemplate": "Mosaico template",
"mosaicoTemplate_plural": "Mosaico templates",
"cannoteDeleteNameDueToTheFollowing": "Cannote delete \"{{name}}\" due to the following dependencies:",
"andMore": "... and more",
"confirmDeletion": "Confirm Deletion",
"areYouSureYouWantToDeleteName?": "Are you sure you want to delete \"{{name}}\"?",
"namespacemustBeSelected": "Namespace must be selected",
"mjml": "MJML",
"html": "HTML",
"countEntriesSelected": "{{ count }} entries selected.",
"loading-1": "Loading ...",
"customFormMustBeSelected": "Custom form must be selected",
"listSaved": "List saved",
"onestepIeNoEmailWithConfirmationLink": "One-step (i.e. no email with confirmation link)",
"onestepWithUnsubscriptionFormIeNoEmail": "One-step with unsubscription form (i.e. no email with confirmation link)",
"twostepIeAnEmailWithConfirmationLinkWill": "Two-step (i.e. an email with confirmation link will be sent)",
"twostepWithUnsubscriptionFormIeAnEmail": "Two-step with unsubscription form (i.e. an email with confirmation link will be sent)",
"manualIeUnsubscriptionHasToBePerformedBy": "Manual (i.e. unsubscription has to be performed by the list administrator)",
"defaultMailtrainForms": "Default Mailtrain Forms",
"customFormsSelectFormBelow": "Custom Forms (select form below)",
"deletingList": "Deleting list ...",
"listDeleted": "List deleted",
"editList": "Edit List",
"createList": "Create List",
"thisIsTheListIdDisplayedToTheSubscribers": "This is the list ID displayed to the subscribers",
"contactEmail": "Contact email",
"contactEmailUsedInSubscriptionFormsAnd": "Contact email used in subscription forms and emails that are sent out. If not filled in, the admin email from the global settings will be used.",
"homepage": "Homepage",
"homepageUrlUsedInSubscriptionFormsAnd": "Homepage URL used in subscription forms and emails that are sent out. If not filled in, the default homepage from global settings will be used.",
"recipientsNameTemplate": "Recipients name template",
"specifyUsingMergeTagsOfThisListHowTo": "Specify using merge tags of this list how to construct full name of the recipient. This full name is used as \"To\" header when sending emails.",
"sendConfigurationThatWillBeUsedFor": "Send configuration that will be used for sending out subscription-related emails.",
"forms": "Forms",
"webAndEmailFormsAndTemplatesUsedIn": "Web and email forms and templates used in subscription management process.",
"allowPublicUsersToSubscribeThemselves": "Allow public users to subscribe themselves",
"unsubscription": "Unsubscription",
"selectHowAnUnsuscriptionRequestBy": "Select how an unsuscription request by subscriber is handled.",
"unsubscribeHeader": "Unsubscribe header",
"doNotSendListUnsubscribeHeaders": "Do not send List-Unsubscribe headers",
"theCustomFormUsedForThisListYouCanCreate": "The custom form used for this list. You can create a form <1>here</1>.",
"fields": "Fields",
"segments": "Segments",
"imports": "Imports",
"customForms-1": "Custom Forms",
"mergeTagIsInvalidMayMustBeUppercaseAnd": "Merge tag is invalid. May must be uppercase and contain only characters A-Z, 0-9, _. It must start with a letter.",
"anotherFieldWithTheSameMergeTagExists": "Another field with the same merge tag exists. Please choose another merge tag.",
"groupHasToBeSelected": "Group has to be selected",
"defaultValueIsNotIntegerNumber": "Default value is not integer number",
"defaultValueIsNotAProperlyFormattedDate": "Default value is not a properly formatted date",
"defaultValueIsNotAProperlyFormatted": "Default value is not a properly formatted birthday date",
"defaultValueIsNotOneOfTheAllowedOptions": "Default value is not one of the allowed options",
"errrorOnLineLine": "Errror on line {{ line }}",
"fieldSaved": "Field saved",
"notVisible": "Not visible",
"endOfList": "End of list",
"fieldSettings": "Field settings",
"defaultValue": "Default value",
"defaultValueUsedWhenTheFieldIsEmpty": "Default value used when the field is empty.",
"options": "Options",
"dateFormat": "Date format",
"mmddyyyy": "MM/DD/YYYY",
"ddmmyyyy": "DD/MM/YYYY",
"mmdd": "MM/DD",
"ddmm": "DD/MM",
"mergeTag": "Merge Tag",
"group": "Group",
"selectGroupToWhichTheOptionsShouldBelong": "Select group to which the options should belong.",
"deletingField": "Deleting field ...",
"fieldDeleted": "Field deleted",
"editField": "Edit Field",
"createField": "Create Field",
"mergeTag-1": "Merge tag",
"fieldOrder": "Field order",
"listingsBefore": "Listings (before)",
"selectTheFieldBeforeWhichThisFieldShould": "Select the field before which this field should appear in listings. To exclude the field from listings, select \"Not visible\".",
"subscriptionFormBefore": "Subscription form (before)",
"selectTheFieldBeforeWhichThisFieldShould-1": "Select the field before which this field should appear in new subscription form. To exclude the field from the new subscription form, select \"Not visible\".",
"managementFormBefore": "Management form (before)",
"selectTheFieldBeforeWhichThisFieldShould-2": "Select the field before which this field should appear in subscription management. To exclude the field from the subscription management form, select \"Not visible\".",
"youCanControlTheAppearanceOfTheMergeTag": "You can control the appearance of the merge tag with this template. The template\n uses handlebars syntax and you can find all values from <1>{'{{values}}'}</1> array, for\n example <3>{'{{#each values}} {{this}} {{/each}}'}</3>. If template is not defined then\n multiple values are joined with commas.",
"specifyTheOptionsToSelectFromInThe": "<0>Specify the options to select from in the following format:<1>key|label</1>. For example:</0>\n <2><0>au|Australia</0></2><3><0>at|Austria</0></3>",
"defaultKeyEgAuUsedWhenTheFieldIsEmpty": "Default key (e.g. <1>au</1> used when the field is empty.')",
"youCanControlTheAppearanceOfTheMergeTag-1": "You can control the appearance of the merge tag with this template. The template\n uses handlebars syntax and you can find all values from <1>{'{{values}}'}</1> array.\n Each entry in the array is an object with attributes <3>key</3> and <5>label</5>.\n For example <7>{'{{#each values}} {{this.value}} {{/each}}'}</7>. If template is not defined then\n multiple values are joined with commas.",
"youCanUseThisTemplateToRenderJsonValues": "You can use this template to render JSON values (if the JSON is an array then the array is\n exposed as <1>values</1>, otherwise you can access the JSON keys directly).",
"text": "Text",
"website": "Website",
"multilineText": "Multi-line text",
"gpgPublicKey": "GPG Public Key",
"number": "Number",
"checkboxesFromOptionFields": "Checkboxes (from option fields)",
"radioButtonsFromOptionFields": "Radio Buttons (from option fields)",
"dropDownFromOptionFields": "Drop Down (from option fields)",
"radioButtonsEnumerated": "Radio Buttons (enumerated)",
"dropDownEnumerated": "Drop Down (enumerated)",
"birthday": "Birthday",
"jsonValueForCustomRendering": "JSON value for custom rendering",
"option": "Option",
"thePlaintextVersionForThisEmail": "The plaintext version for this email",
"layout": "Layout",
"formInputStyle": "Form Input Style",
"thisCssStylesheetDefinesTheAppearanceOf": "This CSS stylesheet defines the appearance of form input elements and alerts",
"webSubscribe": "Web - Subscribe",
"webConfirmSubscriptionNotice": "Web - Confirm Subscription Notice",
"mailConfirmSubscriptionMjml": "Mail - Confirm Subscription (MJML)",
"mailConfirmSubscriptionText": "Mail - Confirm Subscription (Text)",
"mailAlreadySubscribedMjml": "Mail - Already Subscribed (MJML)",
"mailAlreadySubscribedText": "Mail - Already Subscribed (Text)",
"webSubscribedNotice": "Web - Subscribed Notice",
"mailSubscriptionConfirmedMjml": "Mail - Subscription Confirmed (MJML)",
"mailSubscriptionConfirmedText": "Mail - Subscription Confirmed (Text)",
"webManagePreferences": "Web - Manage Preferences",
"webManageAddress": "Web - Manage Address",
"mailConfirmAddressChangeMjml": "Mail - Confirm Address Change (MJML)",
"mailConfirmAddressChangeText": "Mail - Confirm Address Change (Text)",
"webUpdatedNotice": "Web - Updated Notice",
"webUnsubscribe": "Web - Unsubscribe",
"webConfirmUnsubscriptionNotice": "Web - Confirm Unsubscription Notice",
"mailConfirmUnsubscriptionMjml": "Mail - Confirm Unsubscription (MJML)",
"mailConfirmUnsubscriptionText": "Mail - Confirm Unsubscription (Text)",
"webUnsubscribedNotice": "Web - Unsubscribed Notice",
"mailUnsubscriptionConfirmedMjml": "Mail - Unsubscription Confirmed (MJML)",
"mailUnsubscriptionConfirmedText": "Mail - Unsubscription Confirmed (Text)",
"webManualUnsubscribeNotice": "Web - Manual Unsubscribe Notice",
"privacyPolicy": "Privacy policy",
"general": "General",
"subscribe": "Subscribe",
"manage": "Manage",
"unsubscribe": "Unsubscribe",
"dataProtection": "Data protection",
"listOfErrorsInTemplates": "List of errors in templates",
"formsSaved": "Forms saved",
"deletingForm": "Deleting form ...",
"formDeleted": "Form deleted",
"editCustomForms": "Edit Custom Forms",
"createCustomForms": "Create Custom Forms",
"formsPreview": "Forms Preview",
"listToPreviewOn": "List To Preview On",
"selectListWhoseFieldsWillBeUsedToPreview": "Select list whose fields will be used to preview the forms.",
"noteTheseLinksAreSolelyForAQuickPreview": "Note: These links are solely for a quick preview. To get the address of the subscription form, go to the list's subscribers and click on \"Subscription Form\".",
"formPreview": "Form preview:",
"templates": "Templates",
"customFormsUseMjmlForFormattingSeeThe": "Custom forms use MJML for formatting. See the MJML documentation <1>here</1>",
"createCustomForm": "Create Custom Form",
"fileMustBeSelected": "File must be selected",
"csvDelimiterMustNotBeEmpty": "CSV delimiter must not be empty",
"emailMappingHasToBeProvided": "Email mapping has to be provided",
"importSaved": "Import saved",
"file": "File",
"delimiter": "Delimiter",
"preparationInProgressPleaseWaitTillItIs": "Preparation in progress. Please wait till it is done or visit this page later.",
"Select ": " Select ",
"eg": "e.g.:",
"checkImportedEmails": "Check imported emails",
"mapping": "Mapping",
"saveAndEditSettings": "Save and edit settings",
"saveAndRun": "Save and Run",
"deletingImport": "Deleting import ...",
"importDeleted": "Import deleted",
"editImport": "Edit Import",
"createImport": "Create Import",
"source": "Source",
"lastRun": "Last run",
"detailedStatus": "Detailed status",
"row": "Row",
"reason": "Reason",
"importRunStatus": "Import Run Status",
"importName": "Import name",
"importSource": "Import source",
"runStarted": "Run started",
"runFinished": "Run finished",
"runStatus": "Run status",
"processedEntries": "Processed entries",
"newEntries": "New entries",
"failedEntries": "Failed entries",
"error": "Error",
"failedRows": "Failed Rows",
"started": "Started",
"processed": "Processed",
"new": "New",
"failed": "Failed",
"importStatus": "Import Status",
"actions": "Actions",
"start": "Start",
"importRuns": "Import Runs",
"csvFile": "CSV file",
"preparing": "Preparing",
"stopping": "Stopping",
"ready": "Ready",
"preparationFailed": "Preparation failed",
"running": "Running",
"starting": "Starting",
"basicImportOfSubscribers": "Basic import of subscribers",
"unsubscribeEmails": "Unsubscribe emails",
"listName": "List \"{{name}}\"",
"fieldName-1": "Field \"{{name}}\"",
"segmentName": "Segment \"{{name}}\"",
"importName-1": "Import \"{{name}}\"",
"run": "Run",
"customFormsName": "Custom Forms \"{{name}}\"",
"newRule": "New rule",
"segmentSaved": "Segment saved",
"deletingSegment": "Deleting segment ...",
"segmentDeleted": "Segment deleted",
"editSegment": "Edit Segment",
"createSegment": "Create Segment",
"saveAndStay": "Save and Stay",
"saveAndLeave": "Save and Leave",
"segmentOptions": "Segment Options",
"toplevelMatchType": "Toplevel match type",
"addCompositeRule": "Add Composite Rule",
"addRule": "Add Rule",
"rules": "Rules",
"fieldMustBeSelected": "Field must be selected",
"field": "Field",
"select-1": "-- Select --",
"ruleOptions": "Rule Options",
"ok": "OK",
"allRulesMustMatch": "All rules must match",
"atLeastOneRuleMustMatch": "At least one rule must match",
"noRuleMayMatch": "No rule may match",
"equalTo": "Equal to",
"valueInColumnColNameIsEqualToValue": "Value in column \"{{colName}}\" is equal to \"{{value}}\"",
"matchWithSqlLike": "Match (with SQL LIKE)",
"valueInColumnColNameMatchesWithSqlLike": "Value in column \"{{colName}}\" matches (with SQL LIKE) \"{{value}}\"",
"matchWithRegularExpressions": "Match (with regular expressions)",
"valueInColumnColNameMatchesWithRegular": "Value in column \"{{colName}}\" matches (with regular expressions) \"{{value}}\"",
"alphabeticallyBefore": "Alphabetically before",
"valueInColumnColNameIsAlphabetically": "Value in column \"{{colName}}\" is alphabetically before \"{{value}}\"",
"alphabeticallyBeforeOrEqualTo": "Alphabetically before or equal to",
"valueInColumnColNameIsAlphabetically-1": "Value in column \"{{colName}}\" is alphabetically before or equal to \"{{value}}\"",
"alphabeticallyAfter": "Alphabetically after",
"valueInColumnColNameIsAlphabetically-2": "Value in column \"{{colName}}\" is alphabetically after \"{{value}}\"",
"alphabeticallyAfterOrEqualTo": "Alphabetically after or equal to",
"valueInColumnColNameIsAlphabetically-3": "Value in column \"{{colName}}\" is alphabetically after or equal to \"{{value}}\"",
"valueInColumnColNameIsEqualToValue-1": "Value in column \"{{colName}}\" is equal to {{value}}",
"lessThan": "Less than",
"valueInColumnColNameIsLessThanValue": "Value in column \"{{colName}}\" is less than {{value}}",
"lessThanOrEqualTo": "Less than or equal to",
"valueInColumnColNameIsLessThanOrEqualTo": "Value in column \"{{colName}}\" is less than or equal to {{value}}",
"greaterThan": "Greater than",
"valueInColumnColNameIsGreaterThanValue": "Value in column \"{{colName}}\" is greater than {{value}}",
"greaterThanOrEqualTo": "Greater than or equal to",
"valueInColumnColNameIsGreaterThanOrEqual": "Value in column \"{{colName}}\" is greater than or equal to {{value}}",
"on": "On",
"dateInColumnColNameIsValue": "Date in column \"{{colName}}\" is {{value}}",
"before": "Before",
"dateInColumnColNameIsBeforeValue": "Date in column \"{{colName}}\" is before {{value}}",
"beforeOrOn": "Before or on",
"dateInColumnColNameIsBeforeOrOnValue": "Date in column \"{{colName}}\" is before or on {{value}}",
"after": "After",
"dateInColumnColNameIsAfterValue": "Date in column \"{{colName}}\" is after {{value}}",
"afterOrOn": "After or on",
"dateInColumnColNameIsAfterOrOnValue": "Date in column \"{{colName}}\" is after or on {{value}}",
"onXthDayBeforeafterCurrentDate": "On x-th day before/after current date",
"dateInColumnColNameIsTheCurrentDate": "Date in column \"{{colName}}\" is the current date",
"dateInColumnColNameIsTheValuethDayAfter": "Date in column \"{{colName}}\" is the {{value}}-th day after the current date",
"dateInColumnColNameIsTheValuethDayBefore": "Date in column \"{{colName}}\" is the {{value}}-th day before the current date",
"beforeXthDayBeforeafterCurrentDate": "Before x-th day before/after current date",
"dateInColumnColNameIsBeforeTheCurrent": "Date in column \"{{colName}}\" is before the current date",
"dateInColumnColNameIsBeforeTheValuethDay": "Date in column \"{{colName}}\" is before the {{value}}-th day after the current date",
"dateInColumnColNameIsBeforeTheValuethDay-1": "Date in column \"{{colName}}\" is before the {{value}}-th day before the current date",
"beforeOrOnXthDayBeforeafterCurrentDate": "Before or on x-th day before/after current date",
"dateInColumnColNameIsBeforeOrOnThe": "Date in column \"{{colName}}\" is before or on the current date",
"dateInColumnColNameIsBeforeOrOnThe-1": "Date in column \"{{colName}}\" is before or on the {{value}}-th day after the current date",
"dateInColumnColNameIsBeforeOrOnThe-2": "Date in column \"{{colName}}\" is before or on the {{value}}-th day before the current date",
"afterXthDayBeforeafterCurrentDate": "After x-th day before/after current date",
"dateInColumnColNameIsAfterTheCurrentDate": "Date in column \"{{colName}}\" is after the current date",
"dateInColumnColNameIsAfterTheValuethDay": "Date in column \"{{colName}}\" is after the {{value}}-th day after the current date",
"dateInColumnColNameIsAfterTheValuethDay-1": "Date in column \"{{colName}}\" is after the {{value}}-th day before the current date",
"afterOrOnXthDayBeforeafterCurrentDate": "After or on x-th day before/after current date",
"dateInColumnColNameIsAfterOrOnTheCurrent": "Date in column \"{{colName}}\" is after or on the current date",
"dateInColumnColNameIsAfterOrOnTheValueth": "Date in column \"{{colName}}\" is after or on the {{value}}-th day after the current date",
"dateInColumnColNameIsAfterOrOnTheValueth-1": "Date in column \"{{colName}}\" is after or on the {{value}}-th day before the current date",
"isSelected": "Is selected",
"valueInColumnColNameIsSelected": "Value in column \"{{colName}}\" is selected",
"isNotSelected": "Is not selected",
"valueInColumnColNameIsNotSelected": "Value in column \"{{colName}}\" is not selected",
"keyEqualTo": "Key equal to",
"theSelectedKeyInColumnColNameIsEqualTo": "The selected key in column \"{{colName}}\" is equal to \"{{value}}\"",
"keyMatchWithSqlLike": "Key match (with SQL LIKE)",
"theSelectedKeyInColumnColNameMatchesWith": "The selected key in column \"{{colName}}\" matches (with SQL LIKE) \"{{value}}\"",
"keyMatchWithRegularExpressions": "Key match (with regular expressions)",
"theSelectedKeyInColumnColNameMatchesWith-1": "The selected key in column \"{{colName}}\" matches (with regular expressions) \"{{value}}\"",
"keyAlphabeticallyBefore": "Key alphabetically before",
"theSelectedKeyInColumnColNameIs": "The selected key in column \"{{colName}}\" is alphabetically before \"{{value}}\"",
"keyAlphabeticallyBeforeOrEqualTo": "Key alphabetically before or equal to",
"theSelectedKeyInColumnColNameIs-1": "The selected key in column \"{{colName}}\" is alphabetically before or equal to \"{{value}}\"",
"keyAlphabeticallyAfter": "Key alphabetically after",
"theSelectedKeyInColumnColNameIs-2": "The selected key in column \"{{colName}}\" is alphabetically after \"{{value}}\"",
"keyAlphabeticallyAfterOrEqualTo": "Key alphabetically after or equal to",
"theSelectedKeyInColumnColNameIs-3": "The selected key in column \"{{colName}}\" is alphabetically after or equal to \"{{value}}\"",
"value": "Value",
"valueMustNotBeEmpty": "Value must not be empty",
"valueMustBeANumber": "Value must be a number",
"numberOfDays": "Number of days",
"beforeAfter": "Before/After",
"beforeCurrentDate": "Before current date",
"afterCurrentDate": "After current date",
"numberOfDaysMustNotBeEmpty": "Number of days must not be empty",
"numberOfDaysMustBeANumber": "Number of days must be a number",
"emailAddress-1": "Email address",
"signupCountry": "Signup country",
"signUpDate": "Sign up date",
"anotherSubscriptionWithTheSameEmail": "Another subscription with the same email already exists.",
"susbscriptionSaved": "Susbscription saved",
"itSeemsThatAnotherSubscriptionWithThe": "It seems that another subscription with the same email has been created in the meantime. Refresh your page to start anew. Please note that your changes will be lost.",
"notSelected": "Not selected",
"areYouSureYouWantToDeleteSubscriptionFor": "Are you sure you want to delete subscription for \"{{email}}\"?",
"deletingSubscription": "Deleting subscription ...",
"subscriptionDeleted": "Subscription deleted",
"editSubscription": "Edit Subscription",
"createSubscription": "Create Subscription",
"timezone": "Timezone",
"subscriptionStatus": "Subscription status",
"testUser?": "Test user?",
"ifCheckedThenThisSubscriptionCanBeUsed": "If checked then this subscription can be used for previewing campaign messages",
"erased": "[ERASED]",
"confirmUnsubscription": "Confirm Unsubscription",
"areYouSureYouWantToUnsubscribeEmail?": "Are you sure you want to unsubscribe {{email}}?",
"unsubscribingEmail": "Unsubscribing {{email}}",
"emailUnsubscribed": "{{email}} unsubscribed",
"confirmEmailBlacklisting": "Confirm Email Blacklisting",
"areYouSureYouWantToBlacklistEmail?": "Are you sure you want to blacklist {{email}}?",
"blacklistingEmail": "Blacklisting {{email}}",
"emailBlacklisted": "{{email}} blacklisted",
"allSubscriptions": "All subscriptions",
"subscriptionForm": "Subscription Form",
"exportAsCsv": "Export as CSV",
"addSubscriber": "Add Subscriber",
"subscribed": "Subscribed",
"unubscribed": "Unubscribed",
"parentNamespaceMustBeSelected": "Parent Namespace must be selected",
"namespaceSaved": "Namespace saved",
"thereHasBeenALoopDetectedInTheAssignment": "There has been a loop detected in the assignment of the parent namespace. This is most likely because someone else has changed the parent of some namespace in the meantime. Refresh your page to start anew. Please note that your changes will be lost.",
"itSeemsThatTheParentNamespaceHasBeen": "It seems that the parent namespace has been deleted in the meantime. Refresh your page to start anew. Please note that your changes will be lost.",
"deletingNamespace": "Deleting namespace ...",
"namespaceDeleted": "Namespace deleted",
"editNamespace": "Edit Namespace",
"createNamespace": "Create Namespace",
"parentNamespace": "Parent Namespace",
"namespaces": "Namespaces",
"namespaceName": "Namespace \"{{name}}\"",
"reportTemplateMustBeSelected": "Report template must be selected",
"exactlyOneItemHasToBeSelected": "Exactly one item has to be selected",
"atLeastCountItemsHaveToBeSelected": "At least {{ count }} item(s) have to be selected",
"atMostCountItemsCanToBeSelected": "At most {{ count }} item(s) can to be selected",
"reportParametersAreNotSelectedWaitFor": "Report parameters are not selected. Wait for them to get displayed and then fill them in.",
"reportSaved": "Report saved",
"unknownFieldTypeType": "Unknown field type \"{{type}}\"",
"deletingReport": "Deleting report ...",
"reportDeleted": "Report deleted",
"editReport": "Edit Report",
"createReport": "Create Report",
"reportTemplate-1": "Report Template",
"reportParameters": "Report parameters",
"loadingReportTemplate": "Loading report template...",
"processing-1": "Processing",
"view": "View",
"refreshReport": "Refresh report",
"reportGenerationFailed": "Report generation failed",
"regenerateReport": "Regenerate report",
"viewConsoleOutput": "View console output",
"reportTemplates": "Report Templates",
"reports": "Reports",
"reportName": "Report {{name}}",
"loadingReport": "Loading report ...",
"outputForReportName": "Output for report {{name}}",
"loadingReportOutput": "Loading report output ...",
"reportIsBeingGenerated": "Report is being generated",
"reportNotGenerated": "Report not generated",
"refresh": "Refresh",
"reportName-1": "Report \"{{name}}\"",
"output": "Output",
"templateName": "Template \"{{name}}\"",
"mimeTypeMustBeSelected": "MIME Type must be selected",
"syntaxErrorInTheUserFieldsSpecification": "Syntax error in the user fields specification",
"reportTemplateSaved": "Report template saved",
"deletingReportTemplate": "Deleting report template ...",
"reportTemplateDeleted": "Report template deleted",
"editReportTemplate": "Edit Report Template",
"createReportTemplate": "Create Report Template",
"csv": "CSV",
"userSelectableFields": "User selectable fields",
"jsonSpecificationOfUserSelectableFields": "JSON specification of user selectable fields.",
"dataProcessingCode": "Data processing code",
"renderingTemplate": "Rendering template",
"writeTheBodyOfTheJavaScriptFunctionWith": "Write the body of the JavaScript function with signature <1>function(inputs, callback)</1> that returns an object to be rendered by the Handlebars template below.",
"useHtmlWithHandlebarsSyntaxSee": "Use HTML with Handlebars syntax. See documentation <1>here</1>.",
"blank": "Blank",
"openCounts": "Open counts",
"openCountsAsCsv": "Open counts as CSV",
"aggregatedOpenCounts": "Aggregated open counts",
"current": "(current)",
"toggleNavigation": "Toggle navigation",
"administration": "Administracion",
"users": "Users",
"globalSettings": "Global Settings",
"sendConfigurations": "Send configurations",
"logOut": "Log out",
"home": "Home",
"sourceOnGitHub": "Source on GitHub",
"mailerTypeMustBeSelected": "Mailer type must be selected",
"verpHostnameMustNotBeEmpty": "VERP hostname must not be empty",
"sendConfigurationSaved": "Send configuration saved",
"deletingSendConfiguration": "Deleting send configuration ...",
"sendConfigurationDeleted": "Send configuration deleted",
"editSendConfiguration": "Edit Send Configuration",
"createSendConfiguration": "Create Send Configuration",
"emailHeader": "Email Header",
"defaultFromEmail": "Default \"from\" email",
"overridable": "Overridable",
"defaultFromName": "Default \"from\" name",
"defaultReplytoEmail": "Default \"reply-to\" email",
"subject": "Subject",
"xMailer": "X-Mailer",
"verpBounceHandling": "VERP Bounce Handling",
"verpStatus": "VERP status",
"serverHostname": "Server hostname",
"theVerpServerHostnameEgBouncesexamplecom": "The VERP server hostname, eg. bounces.example.com",
"verpBounceHandlingServerHostnameThis": "VERP bounce handling server hostname. This hostname is used in the SMTP envelope FROM address and the MX DNS records should point to this server",
"disableSenderHeader": "Disable sender header",
"withDmarcTheReturnPathAndFromAddressMust": "With DMARC, the Return-Path and From address must match the same domain. By default we get around this by using the VERP address in the Sender header, with the side effect that some email clients diplay an ugly on behalf of message. You can safely disable this Sender header if you're not using DMARC or your VERP hostname is in the same domain as the From address.",
"mailtrainIsAbleToUseVerpBasedRoutingTo": "<0>Mailtrain is able to use VERP based routing to detect bounces. In this case the message is sent to the recipient using a custom VERP address as the return path of the message. If the message is not accepted a bounce email is sent to this special VERP address and thus a bounce is detected.</0>",
"toGetVerpWorkingYouNeedToSetUpADnsMx": "<0>To get VERP working you need to set up a DNS MX record that points to your Mailtrain hostname. You must also ensure that Mailtrain VERP interface is available from port 25 of your server (port 25 usually requires root user privileges). This way if anyone tries to send email to someuser@verp-hostname then the email should end up to this server.</0>",
"verpUsuallyOnlyWorksIfYouAreUsingYourOwn": "<0>VERP usually only works if you are using your own SMTP server. Regular relay services (SES, SparkPost, Gmail etc.) tend to remove the VERP address from the message.</0>",
"verpBounceHandlingServerIsNotEnabled": "<0>VERP bounce handling server is not enabled. Modify your server configuration file and restart server to enable it.</0>",
"sendConfigurations-1": "Send Configurations",
"labelMustNotBeEmpty": "{{label}} must not be empty",
"labelMustBeANumber": "{{label}} must be a number",
"genericSmtp": "Generic SMTP",
"zoneMta": "Zone MTA",
"amazonSes": "Amazon SES",
"doNotUseEncryption": "Do not use encryption",
"useTls UsuallySelectedForPort465": "Use TLS usually selected for port 465",
"useStarttls UsuallySelectedForPort587": "Use STARTTLS usually selected for port 587 and 25",
"useast1": "US-EAST-1",
"uswest2": "US-WEST-2",
"euwest1": "EU-WEST-1",
"builtinZoneMta": "Built-in ZoneMTA",
"dynamicConfigurationOfDkimKeysViaZoneMt": "Dynamic configuration of DKIM keys via ZoneMTA's Mailtrain plugin",
"dynamicConfigurationOfDkimKeysViaZoneMt-1": "Dynamic configuration of DKIM keys via ZoneMTA's HTTP config plugin",
"noDynamicConfigurationOfDkimKeys": "No dynamic configuration of DKIM keys",
"mailerSettings": "Mailer Settings",
"mailerType": "Mailer type",
"hostname": "Hostname",
"hostnameEgSmtpexamplecom": "Hostname, eg. smtp.example.com",
"port": "Port",
"portEg465AutodetectedIfLeftBlank": "Port, eg. 465. Autodetected if left blank",
"encryption": "Encryption",
"enableSmtpAuthentication": "Enable SMTP authentication",
"usernameEgMyaccount@examplecom": "Username, eg. myaccount@example.com",
"advancedMailerSettings": "Advanced Mailer Settings",
"logSmtpTransactions": "Log SMTP transactions",
"allowSelfsignedCertificates": "Allow self-signed certificates",
"maxConnections": "Max connections",
"theCountOfMaxConnectionsEg10": "The count of max connections, eg. 10",
"theCountOfMaximumSimultaneousConnections": "The count of maximum simultaneous connections to make against the SMTP server (defaults to 5). This limit is per sending process.",
"maxMessages": "Max messages",
"theCountOfMaxMessagesEg100": "The count of max messages, eg. 100",
"theNumberOfMessagesToSendThroughASingle": "The number of messages to send through a single connection before the connection is closed and reopened (defaults to 100)",
"throttling": "Throttling",
"messagesPerHourEg1000": "Messages per hour eg. 1000",
"maximumNumberOfMessagesToSendInAnHour": "Maximum number of messages to send in an hour. Leave empty or zero for no throttling. If your provider uses a different speed limit (messages/minute or messages/second) then convert this limit into messages/hour (1m/s => 3600m/h). This limit is per sending process.",
"dynamicConfiguration": "Dynamic configuration",
"dkimSigning": "DKIM Signing",
"zoneMtaDkimApiKey": "ZoneMTA DKIM API key",
"secretValueKnownToZoneMtaForRequesting": "Secret value known to ZoneMTA for requesting DKIM key information. If this value was generated by the Mailtrain installation script then you can keep it as it is.",
"dkimDomain": "DKIM domain",
"leaveBlankToUseTheSenderEmailAddress": "Leave blank to use the sender email address domain.",
"dkimKeySelector": "DKIM key selector",
"signingIsDisabledWithoutAValidSelector": "Signing is disabled without a valid selector value.",
"dkimPrivateKey": "DKIM private key",
"beginsWithBeginRsaPrivateKey": "Begins with \"-----BEGIN RSA PRIVATE KEY-----\"",
"signingIsDisabledWithoutAValidPrivateKey": "Signing is disabled without a valid private key.",
"accessKey": "Access key",
"awsAccessKeyId": "AWS access key ID",
"awsSecretAccessKey": "AWS secret access key",
"region": "Region",
"ifYouAreUsingZoneMtaThenMailtrainCan": "<0>If you are using ZoneMTA then Mailtrain can provide a DKIM key for signing all outgoing messages. Other services usually provide their own means to DKIM sign your messages.</0>",
"doNotUseSensitiveKeysHereThePrivateKeyIs": "<0>Do not use sensitive keys here. The private key is not encrypted in the database.</0>",
"globalSettingsSaved": "Global settings saved",
"adminEmail": "Admin email",
"thisEmailIsUsedAsTheMainContactAndAsA": "This email is used as the main contact and as a default email address if no email address is specified in list settings.",
"defaultHomepageUrl": "Default homepage URL",
"thisUrlWillBeUsedInListSubscriptionForms": "This URL will be used in list subscription forms if no homepage is specified in list settings.",
"trackingId": "Tracking ID",
"uaxxxxxxx": "UA-XXXXX-XX",
"enterGoogleAnalyticsTrackingCode": "Enter Google Analytics tracking code",
"googleMapsApiKey": "Google Maps API Key",
"xxxxxx": "XXXXXX",
"theMapOverviewInCampaignStatistics": "The map overview in campaign statistics requires a Google Maps API key. Please enter it here. If no key is given, Google may throttle map requests, which will result in occassional unavailability of the map in the campaign statistics.",
"frontpageShoutOut": "Frontpage shout out",
"htmlCodeShownInTheFrontPageHeaderSection": "HTML code shown in the front page header section",
"gpgSigning": "GPG Signing",
"privateKeyPassphrase": "Private key passphrase",
"passphraseForTheKeyIfSet": "Passphrase for the key if set",
"onlyFillThisIfYourPrivateKeyIsEncrypted": "Only fill this if your private key is encrypted with a passphrase",
"gpgPrivateKey": "GPG private key",
"beginsWithBeginPgpPrivateKeyBlock": "Begins with '-----BEGIN PGP PRIVATE KEY BLOCK-----'",
"thisValueIsOptionalIfYouDoNotProvideA": "This value is optional. If you do not provide a private key GPG encrypted messages are sent without signing.",
"onlyMessagesThatAreEncryptedCanBeSigned": "<0>Only messages that are encrypted can be signed. Subsribers who have not set up a GPG public key in their profile receive normal email messages. Users with GPG key set receive encrypted messages and if you have signing key also set, the messages are signed with this key.</0>",
"doNotUseSensitiveKeysHereThePrivateKey": "<0>Do not use sensitive keys here. The private key and passphrase are not encrypted in the database.</0>",
"userMustNotBeEmpty": "User must not be empty",
"roleMustBeSelected": "Role must be selected",
"role": "Role",
"addUser": "Add User",
"user": "User",
"existingUsers": "Existing Users",
"sharesForUserUsername": "Shares for user \"{{username}}\"",
"templateSaved": "Template saved",
"deletingTemplate": "Deleting template ...",
"templateDeleted": "Template deleted",
"editTemplate": "Edit Template",
"createTemplate": "Create Template",
"saveAndEditTemplate": "Save and edit template",
"mosaicoTemplates": "Mosaico Templates",
"sendConfigurationHasToBeSelected": "Send configuration has to be selected.",
"listHasToBeSelected": "List has to be selected.",
"mosaico": "Mosaico",
"templateContentHtml": "Template content (HTML)",
"mosaicoTemplateDesigner": "Mosaico Template Designer",
"mosaicoTemplateMustBeSelected": "Mosaico template must be selected",
"mosaicoWithPredefinedTemplates": "Mosaico with predefined templates",
"mosaicoTemplate-1": "Mosaico Template",
"grapesJs": "GrapesJS",
"grapesJsTemplateDesigner": "GrapesJS Template Designer",
"ckEditor4": "CKEditor 4",
"ckEditor4TemplateDesigner": "CKEditor 4 Template Designer",
"codeEditor": "Code Editor",
"codeEditorTemplateDesigner": "Code Editor Template Designer",
"mergeTagReference": "Merge tag reference",
"templateContentPlainText": "Template content (plain text)",
"mergeTagsAreTagsThatAreReplacedBefore": "\n <1>Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <1>[TAG_NAME]</1> or <3>[TAG_NAME/fallback]</3> where <5>fallback</5> is an optional text value used when <7>TAG_NAME</7> is empty.</1>\n ",
"youCanUseAnyOfTheStandardMergeTagsBelow": "\n <1>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.</1>\n ",
"urlThatPointsToTheUnsubscribePage": "URL that points to the unsubscribe page",
"urlThatPointsToThePreferencesPageOfThe": "URL that points to the preferences page of the subscriber",
"urlToPreviewTheMessageInABrowser": "URL to preview the message in a browser",
"recipientNameAsItAppearsInEmailsToHeader": "Recipient name as it appears in email's 'To' header",
"uniqueIdThatIdentifiesTheRecipient": "Unique ID that identifies the recipient",
"uniqueIdThatIdentifiesTheListUsedForThis": "Unique ID that identifies the list used for this campaign",
"uniqueIdThatIdentifiesCurrentCampaign": "Unique ID that identifies current campaign",
"forRssCampaignsTheFollowingFurtherTags": "\n <1>For RSS campaigns, the following further tags can be used.</1>\n ",
"rssEntryTitle": "RSS entry title",
"rssEntryDate": "RSS entry date",
"rssEntryLink": "RSS entry link",
"contentOfAnRssEntry": "Content of an RSS entry",
"rssEntrySummary": "RSS entry summary",
"rssEntryImageUrl": "RSS entry image URL",
"toExtractTheTextFromHtmlClickHerePlease": "To extract the text from HTML click <1>here</1>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <3>Premailer API</3>, a third party service. Their Terms of Service and Privacy Policy apply.",
"mosaicoTemplateSaved": "Mosaico template saved",
"deletingMosaicoTemplate": "Deleting Mosaico template ...",
"mosaicoTemplateDeleted": "Mosaico template deleted",
"editMosaicoTemplate": "Edit Mosaico Template",
"createMosaicoTemplate": "Create Mosaico Template",
"blockThumbnails": "Block thumbnails",
"versafixOne": "Versafix One",
"templateContent": "Template content",
"mosaicoTemplateName": "Mosaico Template \"{{name}}\"",
"theseFilesArePubliclyAvailableViaHttpSo-1": "These files are publicly available via HTTP so that they can be linked to from the Mosaico template.",
"theseFilesWillBeUsedByMosaicoToSearchFor": "These files will be used by Mosaico to search for block thumbnails (the \"edres\" directory). Place here one file per block type that you have defined in the Mosaico template. Each file must have the same name as the block id. The file will be used as the thumbnail of the corresponding block.",
"theUserNameAlreadyExistsInTheSystem": "The user name already exists in the system.",
"userSaved": "User saved",
"theUsernameIsAlreadyAssignedToAnother": "The username is already assigned to another user.",
"theEmailIsAlreadyAssignedToAnotherUser-1": "The email is already assigned to another user.",
"deletingUser": "Deleting user ...",
"userDeleted": "User deleted",
"editUser": "Edit User",
"createUser": "Create User",
"userName": "User Name",
"repeatPassword": "Repeat Password",
"deleteUser": "Delete User",
"userName-1": "User \"{{name}}\"",
"shares": "Shares",
"subscriptionconfirmed": "Subscription Confirmed",
"listEmailAddressAlreadyRegistered": "{{list}}: Email Address Already Registered",
"listPleaseConfirmEmailChangeIn": "{{list}}: Please Confirm Email Change in Subscription",
"pleaseConfirmSubscription": "Please Confirm Subscription",
"listPleaseConfirmUnsubscription": "{{list}}: Please Confirm Unsubscription",
"listUnsubscriptionConfirmed": "{{list}}: Unsubscription Confirmed",
"invalidEmailAddressEmailMxRecordNotFound": "Invalid email address \"{{email}}\": MX record not found for domain",
"invalidEmailAddressEmailAddressDomainNot": "Invalid email address \"{{email}}\": Address domain not found",
"invalidEmailAddressEmailAddressDomain": "Invalid email address \"{{email}}\": Address domain name is required",
"invalidEmailGeneric": "Invalid email address \"{{email}}\"",
"mailerPasswordChangeRequest": "Mailer password change request",
"mailtrain": "Mailtrain",
"emailAddressChanged": "Email address changed",
"emailAddressNotSet": "Email address not set",
"nothingSeemsToBeChanged": "Nothing seems to be changed",
"anEmailWithFurtherInstructionsHasBeen": "An email with further instructions has been sent to the provided address",
"foundAddedMessagesNewCampaignMessages": "Found {{addedMessages}} new campaign messages from feed {{campaignId}}",
"foundNothingNewFromTheFeed": "Found nothing new from the feed",
"missingEmail": "Missing email",
"emailAddress-2": "Email Address",
"wantToChangeIt?": "want to change it?",
"downloadSignatureVerificationKey": "Download signature verification key",
"beginsWithAnd#39BeginPgpPublicKeyBloc": "Begins with &#39;-----BEGIN PGP PUBLIC KEY BLOCK-----&#39;",
"insertYourGpgPublicKeyHereToEncrypt": "Insert your GPG public key here to encrypt messages sent to your address <em>(optional)</em>",
"warning!": "Warning!",
"javaScriptMustBeEnabledInOrderForThis": "JavaScript must be enabled in order for this form to work",
"existingEmailAddress": "Existing Email Address",
"newEmailAddress": "New Email Address",
"youWillReceiveAConfirmationRequestToYour": "You will receive a confirmation request to your new email address that you need to accept before your email is actually changed",
"updateEmailAddress": "Update Email Address",
"updateProfile": "Update Profile",
"subscribeToList": "Subscribe to list",
"thePasswordMustBeAtLeastMinLength": "The password must be at least {{ minLength }} characters long",
"thePasswordMustBeFewerThanMaxLength": "The password must be fewer than {{ maxLength }} characters",
"thePasswordMayNotContainSequencesOfThree": "The password may not contain sequences of three or more repeated characters",
"thePasswordMustContainAtLeastOne": "The password must contain at least one lowercase letter",
"thePasswordMustContainAtLeastOne-1": "The password must contain at least one uppercase letter",
"thePasswordMustContainAtLeastOneNumber": "The password must contain at least one number",
"thePasswordMustContainAtLeastOneSpecial": "The password must contain at least one special character"
}

View file

@ -3887,7 +3887,8 @@
"ansi-regex": { "ansi-regex": {
"version": "2.1.1", "version": "2.1.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
@ -4302,7 +4303,8 @@
"safe-buffer": { "safe-buffer": {
"version": "5.1.1", "version": "5.1.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"safer-buffer": { "safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
@ -4358,6 +4360,7 @@
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"ansi-regex": "^2.0.0" "ansi-regex": "^2.0.0"
} }
@ -4401,12 +4404,14 @@
"wrappy": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"yallist": { "yallist": {
"version": "3.0.2", "version": "3.0.2",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
} }
} }
}, },

@ -1 +1 @@
Subproject commit a9ad4bab17475ab8646a0294338df59aa3864cb9 Subproject commit 4d53d4b030273f25062fcc3c2328987d5a39cffc

View file

@ -320,10 +320,6 @@ function createApp(appType) {
app.use('/', index.getRouter(appType)); app.use('/', index.getRouter(appType));
// Error handlers
if (app.get('env') === 'development' || app.get('env') === 'test') {
// development error handler
// will print stacktrace
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
if (!err) { if (!err) {
return next(); return next();
@ -332,49 +328,7 @@ function createApp(appType) {
if (req.needsRESTJSONResponse) { if (req.needsRESTJSONResponse) {
const resp = { const resp = {
message: err.message, message: err.message,
error: err error: config.sendStacktracesToClient ? err : {}
};
if (err instanceof interoperableErrors.InteroperableError) {
resp.type = err.type;
resp.data = err.data;
}
res.status(err.status || 500).json(resp);
} else if (req.needsAPIJSONResponse) {
const resp = {
error: err.message || err,
data: []
};
return res.status(err.status || 500).json(resp);
} else {
if (err instanceof interoperableErrors.NotLoggedInError) {
return res.redirect(getTrustedUrl('/login?next=' + encodeURIComponent(req.originalUrl)));
} else {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
}
}
});
} else {
// production error handler
// no stacktraces leaked to user
app.use((err, req, res, next) => {
if (!err) {
return next();
}
if (req.needsRESTJSONResponse) {
const resp = {
message: err.message,
error: {}
}; };
if (err instanceof interoperableErrors.InteroperableError) { if (err instanceof interoperableErrors.InteroperableError) {
@ -382,6 +336,7 @@ function createApp(appType) {
resp.data = err.data; resp.data = err.data;
} }
log.verbose('HTTP', err);
res.status(err.status || 500).json(resp); res.status(err.status || 500).json(resp);
} else if (req.needsAPIJSONResponse) { } else if (req.needsAPIJSONResponse) {
@ -390,6 +345,7 @@ function createApp(appType) {
data: [] data: []
}; };
log.verbose('HTTP', err);
return res.status(err.status || 500).json(resp); return res.status(err.status || 500).json(resp);
} else { } else {
@ -398,15 +354,15 @@ function createApp(appType) {
if (err instanceof interoperableErrors.NotLoggedInError) { if (err instanceof interoperableErrors.NotLoggedInError) {
return res.redirect(getTrustedUrl('/login?next=' + encodeURIComponent(req.originalUrl))); return res.redirect(getTrustedUrl('/login?next=' + encodeURIComponent(req.originalUrl)));
} else { } else {
log.verbose('HTTP', err);
res.status(err.status || 500); res.status(err.status || 500);
res.render('error', { res.render('error', {
message: err.message, message: err.message,
error: {} error: config.sendStacktracesToClient ? err : {}
}); });
} }
} }
}); });
}
return app; return app;
} }

View file

@ -42,6 +42,7 @@ defaultLanguage: en-US
# Enabled languages # Enabled languages
enabledLanguages: enabledLanguages:
- en-US - en-US
- es-ES
- fk-FK - fk-FK
# Inject custom scripts in subscription/layout.mjml.hbs # Inject custom scripts in subscription/layout.mjml.hbs
@ -72,6 +73,7 @@ redis:
log: log:
# silly|verbose|info|http|warn|error|silent # silly|verbose|info|http|warn|error|silent
level: info level: info
sendStacktracesToClient: false
www: www:
# HTTP port to listen on for trusted requests (logged-in users) # HTTP port to listen on for trusted requests (logged-in users)
@ -217,6 +219,8 @@ builtinZoneMTA:
redis: redis://localhost:6379/2 redis: redis://localhost:6379/2
log: log:
level: warn level: warn
processes: 2
connections: 5
seleniumWebDriver: seleniumWebDriver:
browser: phantomjs browser: phantomjs
@ -228,7 +232,7 @@ roles:
name: Global Master name: Global Master
admin: true admin: true
description: All permissions description: All permissions
permissions: [rebuildPermissions, createJavascriptWithROAccess, manageBlacklist, manageSettings, setupAutomation] permissions: [rebuildPermissions, createJavascriptWithROAccess, displayManageUsers, manageBlacklist, manageSettings, setupAutomation]
rootNamespaceRole: master rootNamespaceRole: master
campaignsAdmin: campaignsAdmin:
name: Campaigns Admin name: Campaigns Admin

View file

@ -24,6 +24,7 @@ const { AppType } = require('../shared/app');
const builtinZoneMta = require('./lib/builtin-zone-mta'); const builtinZoneMta = require('./lib/builtin-zone-mta');
const { uploadedFilesDir } = require('./lib/file-helpers'); const { uploadedFilesDir } = require('./lib/file-helpers');
const { filesDir } = require('./models/files');
const trustedPort = config.www.trustedPort; const trustedPort = config.www.trustedPort;
const sandboxPort = config.www.sandboxPort; const sandboxPort = config.www.sandboxPort;
@ -113,6 +114,7 @@ dbcheck(err => { // Check if database needs upgrading before starting the server
startHTTPServer(AppType.SANDBOXED, 'sandbox', sandboxPort, () => startHTTPServer(AppType.SANDBOXED, 'sandbox', sandboxPort, () =>
startHTTPServer(AppType.PUBLIC, 'public', publicPort, async () => { startHTTPServer(AppType.PUBLIC, 'public', publicPort, async () => {
await privilegeHelpers.ensureMailtrainDir(filesDir);
await privilegeHelpers.ensureMailtrainDir(uploadedFilesDir); await privilegeHelpers.ensureMailtrainDir(uploadedFilesDir);
privilegeHelpers.dropRootPrivileges(); privilegeHelpers.dropRootPrivileges();

View file

@ -0,0 +1,54 @@
'use strict';
async function _logActivity(typeId, data) {
// TODO
}
/*
Extra data:
campaign:
- status : CampaignStatus
list:
- subscriptionId
- subscriptionStatus : SubscriptionStatus
- fieldId
- segmentId
- importId
- importStatus : ImportStatus
*/
async function logEntityActivity(entityTypeId, activityType, entityId, extraData = {}) {
const data = {
...extraData,
type: activityType,
entity: entityId
};
await _logActivity(entityTypeId, data);
}
async function logCampaignTrackerActivity(activityType, campaignId, listId, subscriptionId, extraData = {}) {
const data = {
...extraData,
type: activityType,
campaign: campaignId,
list: listId,
subscription: subscriptionId
};
await _logActivity('campaign_tracker', data);
}
async function logBlacklistActivity(activityType, email) {
const data = {
type: activityType,
email
};
await _logActivity('blacklist', data);
}
module.exports.logEntityActivity = logEntityActivity;
module.exports.logBlacklistActivity = logBlacklistActivity;
module.exports.logCampaignTrackerActivity = logCampaignTrackerActivity;

View file

@ -108,8 +108,8 @@ async function createConfig() {
default: { default: {
preferIPv6: false, preferIPv6: false,
ignoreIPv6: true, ignoreIPv6: true,
processes: 1, processes: config.builtinZoneMTA.processes,
connections: 5, connections: config.builtinZoneMTA.connections,
pool: 'default' pool: 'default'
} }
} }

View file

@ -0,0 +1,32 @@
'use strict';
function convertFileURLs(sourceCustom, fromEntityType, fromEntityId, toEntityType, toEntityId) {
function convertText(text) {
if (text) {
const fromUrl = `/files/${fromEntityType}/file/${fromEntityId}`;
const toUrl = `/files/${toEntityType}/file/${toEntityId}`;
const encodedFromUrl = encodeURIComponent(fromUrl);
const encodedToUrl = encodeURIComponent(toUrl);
text = text.split('[URL_BASE]' + fromUrl).join('[URL_BASE]' + toUrl);
text = text.split('[SANDBOX_URL_BASE]' + fromUrl).join('[SANDBOX_URL_BASE]' + toUrl);
text = text.split('[ENCODED_URL_BASE]' + encodedFromUrl).join('[ENCODED_URL_BASE]' + encodedToUrl);
text = text.split('[ENCODED_SANDBOX_URL_BASE]' + encodedFromUrl).join('[ENCODED_SANDBOX_URL_BASE]' + encodedToUrl);
}
return text;
}
sourceCustom.html = convertText(sourceCustom.html);
sourceCustom.text = convertText(sourceCustom.text);
if (sourceCustom.type === 'mosaico' || sourceCustom.type === 'mosaicoWithFsTemplate') {
sourceCustom.data.model = convertText(sourceCustom.data.model);
sourceCustom.data.model = convertText(sourceCustom.data.model);
sourceCustom.data.metadata = convertText(sourceCustom.data.metadata);
}
}
module.exports.convertFileURLs = convertFileURLs;

View file

@ -394,6 +394,18 @@ class CampaignSender {
try { try {
const info = await mailer.sendMassMail(mail); const info = await mailer.sendMassMail(mail);
status = SubscriptionStatus.SUBSCRIBED; status = SubscriptionStatus.SUBSCRIBED;
/*
ZoneMTA
info.response: 250 Message queued as 1691ad7f7ae00080fd
info.messageId: <e65c9386-e899-7d01-b21e-ec03c3a9d9b4@sathyasai.org>
Postal Mail Server
info.response: 250 OK
info.messageId: <xxxxxxxxx@xxx.xx> (postal messageId)
*/
console.log(`response: ${info.response} messageId: ${info.messageId}`);
response = info.response || info.messageId; response = info.response || info.messageId;
await knex('campaigns').where('id', campaign.id).increment('delivered'); await knex('campaigns').where('id', campaign.id).increment('delivered');

View file

@ -5,6 +5,8 @@ const fork = require('child_process').fork;
const log = require('./log'); const log = require('./log');
const path = require('path'); const path = require('path');
const {ImportStatus, RunStatus} = require('../../shared/imports'); const {ImportStatus, RunStatus} = require('../../shared/imports');
const {ListActivityType} = require('../../shared/activity-log');
const activityLog = require('./activity-log');
let messageTid = 0; let messageTid = 0;
let importerProcess; let importerProcess;
@ -18,11 +20,17 @@ function spawn(callback) {
log.verbose('Importer', 'Spawning importer process'); log.verbose('Importer', 'Spawning importer process');
knex.transaction(async tx => { knex.transaction(async tx => {
await tx('imports').where('status', ImportStatus.PREP_RUNNING).update({status: ImportStatus.PREP_SCHEDULED}); const updateStatus = async (fromStatus, toStatus) => {
await tx('imports').where('status', ImportStatus.PREP_STOPPING).update({status: ImportStatus.PREP_FAILED}); for (const impt of await tx('imports').where('status', fromStatus).select(['id', 'list'])) {
await tx('imports').where('id', impt.id).update({status: toStatus});
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, impt.list, {importId: impt.id, importStatus: toStatus});
}
}
await tx('imports').where('status', ImportStatus.RUN_RUNNING).update({status: ImportStatus.RUN_SCHEDULED}); await updateStatus(ImportStatus.PREP_RUNNING, ImportStatus.PREP_SCHEDULED);
await tx('imports').where('status', ImportStatus.RUN_STOPPING).update({status: ImportStatus.RUN_FAILED}); await updateStatus(ImportStatus.PREP_STOPPING, ImportStatus.PREP_FAILED);
await updateStatus(ImportStatus.RUN_RUNNING, ImportStatus.RUN_SCHEDULED);
await updateStatus(ImportStatus.RUN_STOPPING, ImportStatus.RUN_FAILED);
await tx('import_runs').where('status', RunStatus.RUNNING).update({status: RunStatus.SCHEDULED}); await tx('import_runs').where('status', RunStatus.RUNNING).update({status: RunStatus.SCHEDULED});
await tx('import_runs').where('status', RunStatus.STOPPING).update({status: RunStatus.FAILED}); await tx('import_runs').where('status', RunStatus.STOPPING).update({status: RunStatus.FAILED});

View file

@ -173,7 +173,7 @@ async function _createTransport(sendConfiguration) {
} }
}; };
if (mailerType === MailerType.ZONE_MTA || mailerSettings.zoneMTAType === ZoneMTAType.BUILTIN) { if (mailerType === MailerType.ZONE_MTA && mailerSettings.zoneMtaType === ZoneMTAType.BUILTIN) {
transportOptions.host = config.builtinZoneMTA.host; transportOptions.host = config.builtinZoneMTA.host;
transportOptions.port = config.builtinZoneMTA.port; transportOptions.port = config.builtinZoneMTA.port;
transportOptions.secure = false; transportOptions.secure = false;

View file

@ -129,7 +129,7 @@ async function _sendMail(list, email, template, locale, subjectKey, relativeUrls
}; };
if (list.default_form) { if (list.default_form) {
const form = await forms.getById(contextHelpers.getAdminContext(), list.default_form); const form = await forms.getById(contextHelpers.getAdminContext(), list.default_form, false);
text.template = form['mail_' + template + '_text'] || text.template; text.template = form['mail_' + template + '_text'] || text.template;
html.template = form['mail_' + template + '_html'] || html.template; html.template = form['mail_' + template + '_html'] || html.template;

View file

@ -13,6 +13,7 @@ function loadLanguage(longCode) {
} }
loadLanguage('en-US'); loadLanguage('en-US');
loadLanguage('es-ES');
resourcesCommon['fk-FK'] = convertToFake(resourcesCommon['en-US']); resourcesCommon['fk-FK'] = convertToFake(resourcesCommon['en-US']);
const resources = {}; const resources = {};

View file

@ -6,6 +6,10 @@ const shares = require('./shares');
const tools = require('../lib/tools'); const tools = require('../lib/tools');
const { enforce } = require('../lib/helpers'); const { enforce } = require('../lib/helpers');
const {BlacklistActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
async function listDTAjax(context, params) { async function listDTAjax(context, params) {
shares.enforceGlobalPermission(context, 'manageBlacklist'); shares.enforceGlobalPermission(context, 'manageBlacklist');
@ -44,14 +48,21 @@ async function add(context, email) {
if (!existing) { if (!existing) {
await tx('blacklist').insert({email}); await tx('blacklist').insert({email});
} }
await activityLog.logBlacklistActivity(BlacklistActivityType.ADD, email);
}); });
} }
async function remove(context, email) { async function remove(context, email) {
enforce(email, 'Email has to be set'); enforce(email, 'Email has to be set');
return await knex.transaction(async tx => {
shares.enforceGlobalPermission(context, 'manageBlacklist'); shares.enforceGlobalPermission(context, 'manageBlacklist');
await knex('blacklist').where('email', email).del();
await tx('blacklist').where('email', email).del();
await activityLog.logBlacklistActivity(BlacklistActivityType.REMOVE, email);
});
} }
async function isBlacklisted(email) { async function isBlacklisted(email) {

View file

@ -20,6 +20,10 @@ const senders = require('../lib/senders');
const {LinkId} = require('./links'); const {LinkId} = require('./links');
const feedcheck = require('../lib/feedcheck'); const feedcheck = require('../lib/feedcheck');
const contextHelpers = require('../lib/context-helpers'); const contextHelpers = require('../lib/context-helpers');
const {convertFileURLs} = require('../lib/campaign-content');
const {EntityActivityType, CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const allowedKeysCommon = ['name', 'description', 'segment', 'namespace', const allowedKeysCommon = ['name', 'description', 'segment', 'namespace',
'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url']; 'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
@ -60,18 +64,32 @@ function hash(entity, content) {
return hasher.hash(filteredEntity); return hasher.hash(filteredEntity);
} }
async function listDTAjax(context, params) { async function _listDTAjax(context, namespaceId, params) {
return await dtHelpers.ajaxListWithPermissions( return await dtHelpers.ajaxListWithPermissions(
context, context,
[{ entityTypeId: 'campaign', requiredOperations: ['view'] }], [{ entityTypeId: 'campaign', requiredOperations: ['view'] }],
params, params,
builder => builder.from('campaigns') builder => {
builder = builder.from('campaigns')
.innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace') .innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace')
.whereNull('campaigns.parent'), .whereNull('campaigns.parent');
if (namespaceId) {
builder = builder.where('namespaces.id', namespaceId);
}
return builder;
},
['campaigns.id', 'campaigns.name', 'campaigns.cid', 'campaigns.description', 'campaigns.type', 'campaigns.status', 'campaigns.scheduled', 'campaigns.source', 'campaigns.created', 'namespaces.name'] ['campaigns.id', 'campaigns.name', 'campaigns.cid', 'campaigns.description', 'campaigns.type', 'campaigns.status', 'campaigns.scheduled', 'campaigns.source', 'campaigns.created', 'namespaces.name']
); );
} }
async function listDTAjax(context, params) {
return await _listDTAjax(context, undefined, params);
}
async function listByNamespaceDTAjax(context, namespaceId, params) {
return await _listDTAjax(context, namespaceId, params);
}
async function listChildrenDTAjax(context, campaignId, params) { async function listChildrenDTAjax(context, campaignId, params) {
return await dtHelpers.ajaxListWithPermissions( return await dtHelpers.ajaxListWithPermissions(
context, context,
@ -427,35 +445,6 @@ async function _validateAndPreprocess(tx, context, entity, isCreate, content) {
} }
} }
function convertFileURLs(sourceCustom, fromEntityType, fromEntityId, toEntityType, toEntityId) {
function convertText(text) {
if (text) {
const fromUrl = `/files/${fromEntityType}/file/${fromEntityId}`;
const toUrl = `/files/${toEntityType}/file/${toEntityId}`;
const encodedFromUrl = encodeURIComponent(fromUrl);
const encodedToUrl = encodeURIComponent(toUrl);
text = text.split('[URL_BASE]' + fromUrl).join('[URL_BASE]' + toUrl);
text = text.split('[SANDBOX_URL_BASE]' + fromUrl).join('[SANDBOX_URL_BASE]' + toUrl);
text = text.split('[ENCODED_URL_BASE]' + encodedFromUrl).join('[ENCODED_URL_BASE]' + encodedToUrl);
text = text.split('[ENCODED_SANDBOX_URL_BASE]' + encodedFromUrl).join('[ENCODED_SANDBOX_URL_BASE]' + encodedToUrl);
}
return text;
}
sourceCustom.html = convertText(sourceCustom.html);
sourceCustom.text = convertText(sourceCustom.text);
if (sourceCustom.type === 'mosaico' || sourceCustom.type === 'mosaicoWithFsTemplate') {
sourceCustom.data.model = convertText(sourceCustom.data.model);
sourceCustom.data.model = convertText(sourceCustom.data.model);
sourceCustom.data.metadata = convertText(sourceCustom.data.metadata);
}
}
async function _createTx(tx, context, entity, content) { async function _createTx(tx, context, entity, content) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCampaign'); await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCampaign');
@ -533,6 +522,8 @@ async function _createTx(tx, context, entity, content) {
}).where('id', id); }).where('id', id);
} }
await activityLog.logEntityActivity('campaign', EntityActivityType.CREATE, id, {status: filteredEntity.status});
return id; return id;
}); });
} }
@ -566,7 +557,7 @@ async function updateWithConsistencyCheck(context, entity, content) {
} else if (content === Content.WITHOUT_SOURCE_CUSTOM) { } else if (content === Content.WITHOUT_SOURCE_CUSTOM) {
filteredEntity.data.sourceCustom = existing.data.sourceCustom; filteredEntity.data.sourceCustom = existing.data.sourceCustom;
await namespaceHelpers.validateMove(context, filteredEntity, existing, 'campaign', 'createCampaign', 'delete'); await namespaceHelpers.validateMove(context, filteredEntity, existing, 'campaign', 'createCampaign', 'delete'); // XXX TB - try with entity
} else if (content === Content.ONLY_SOURCE_CUSTOM) { } else if (content === Content.ONLY_SOURCE_CUSTOM) {
const data = existing.data; const data = existing.data;
@ -591,6 +582,8 @@ async function updateWithConsistencyCheck(context, entity, content) {
await tx('campaigns').where('id', entity.id).update(filteredEntity); await tx('campaigns').where('id', entity.id).update(filteredEntity);
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: entity.id }); await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: entity.id });
await activityLog.logEntityActivity('campaign', EntityActivityType.UPDATE, entity.id, {status: filteredEntity.status});
}); });
} }
@ -628,6 +621,8 @@ async function _removeTx(tx, context, id, existing = null) {
.del(); .del();
await tx('campaigns').where('id', id).del(); await tx('campaigns').where('id', id).del();
await activityLog.logEntityActivity('campaign', EntityActivityType.REMOVE, id);
} }
@ -727,9 +722,7 @@ async function _changeStatusByMessageTx(tx, context, message, subscriptionStatus
const statusField = statusFieldMapping[subscriptionStatus]; const statusField = statusFieldMapping[subscriptionStatus];
if (message.status === SubscriptionStatus.SUBSCRIBED) {
await tx('campaigns').increment(statusField, 1).where('id', message.campaign); await tx('campaigns').increment(statusField, 1).where('id', message.campaign);
}
await tx('campaign_messages') await tx('campaign_messages')
.where('id', message.id) .where('id', message.id)
@ -745,10 +738,14 @@ async function changeStatusByCampaignCidAndSubscriptionIdTx(tx, context, campaig
const message = await tx('campaign_messages') const message = await tx('campaign_messages')
.innerJoin('campaigns', 'campaign_messages.campaign', 'campaigns.id') .innerJoin('campaigns', 'campaign_messages.campaign', 'campaigns.id')
.where('campaigns.cid', campaignCid) .where('campaigns.cid', campaignCid)
.where({subscription: subscriptionId, list: listId}); .where({subscription: subscriptionId, list: listId})
.select([
'campaign_messages.id', 'campaign_messages.campaign', 'campaign_messages.list', 'campaign_messages.subscription', 'campaign_messages.status'
])
.first();
if (!message) { if (!message) {
throw new Error('Invalid campaign.') throw new Error('Invalid campaign.');
} }
await _changeStatusByMessageTx(tx, context, message, subscriptionStatus); await _changeStatusByMessageTx(tx, context, message, subscriptionStatus);
@ -863,6 +860,8 @@ async function _changeStatus(context, campaignId, permittedCurrentStates, newSta
status: newState, status: newState,
scheduled scheduled
}); });
await activityLog.logEntityActivity('campaign', CampaignActivityType.STATUS_CHANGE, campaignId, {status: newState});
}); });
senders.scheduleCheck(); senders.scheduleCheck();
@ -949,6 +948,7 @@ module.exports.Content = Content;
module.exports.hash = hash; module.exports.hash = hash;
module.exports.listDTAjax = listDTAjax; module.exports.listDTAjax = listDTAjax;
module.exports.listByNamespaceDTAjax = listByNamespaceDTAjax;
module.exports.listChildrenDTAjax = listChildrenDTAjax; module.exports.listChildrenDTAjax = listChildrenDTAjax;
module.exports.listWithContentDTAjax = listWithContentDTAjax; module.exports.listWithContentDTAjax = listWithContentDTAjax;
module.exports.listOthersWhoseListsAreIncludedDTAjax = listOthersWhoseListsAreIncludedDTAjax; module.exports.listOthersWhoseListsAreIncludedDTAjax = listOthersWhoseListsAreIncludedDTAjax;

View file

@ -16,6 +16,8 @@ const { cleanupFromPost } = require('../lib/helpers');
const Handlebars = require('handlebars'); const Handlebars = require('handlebars');
const { getTrustedUrl, getSandboxUrl, getPublicUrl } = require('../lib/urls'); const { getTrustedUrl, getSandboxUrl, getPublicUrl } = require('../lib/urls');
const { getMergeTagsForBases } = require('../../shared/templates'); const { getMergeTagsForBases } = require('../../shared/templates');
const {ListActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const allowedKeysCreate = new Set(['name', 'key', 'default_value', 'type', 'group', 'settings']); const allowedKeysCreate = new Set(['name', 'key', 'default_value', 'type', 'group', 'settings']);
@ -565,6 +567,8 @@ async function createTx(tx, context, listId, entity) {
await knex.schema.raw('ALTER TABLE `subscription__' + listId + '` ADD `source_' + columnName +'` int(11) DEFAULT NULL'); await knex.schema.raw('ALTER TABLE `subscription__' + listId + '` ADD `source_' + columnName +'` int(11) DEFAULT NULL');
} }
await activityLog.logEntityActivity('list', ListActivityType.CREATE_FIELD, listId, {fieldId: id});
return id; return id;
} }
@ -594,6 +598,8 @@ async function updateWithConsistencyCheck(context, listId, entity) {
await tx('custom_fields').where({list: listId, id: entity.id}).update(filterObject(entity, allowedKeysUpdate)); await tx('custom_fields').where({list: listId, id: entity.id}).update(filterObject(entity, allowedKeysUpdate));
await _sortIn(tx, listId, entity.id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore); await _sortIn(tx, listId, entity.id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore);
await activityLog.logEntityActivity('list', ListActivityType.UPDATE_FIELD, listId, {fieldId: entity.id});
}); });
} }
@ -620,6 +626,8 @@ async function removeTx(tx, context, listId, id) {
await segments.removeRulesByColumnTx(tx, context, listId, existing.column); await segments.removeRulesByColumnTx(tx, context, listId, existing.column);
} }
await activityLog.logEntityActivity('list', ListActivityType.REMOVE_FIELD, listId, {fieldId: id});
} }
async function remove(context, listId, id) { async function remove(context, listId, id) {

View file

@ -10,6 +10,8 @@ const {ImportSource, MappingType, ImportStatus, RunStatus, prepFinished, prepFin
const fs = require('fs-extra-promise'); const fs = require('fs-extra-promise');
const path = require('path'); const path = require('path');
const importer = require('../lib/importer'); const importer = require('../lib/importer');
const {ListActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const files = require('./files'); const files = require('./files');
const filesDir = path.join(files.filesDir, 'imports'); const filesDir = path.join(files.filesDir, 'imports');
@ -117,6 +119,8 @@ async function create(context, listId, entity, files) {
const ids = await tx('imports').insert(filteredEntity); const ids = await tx('imports').insert(filteredEntity);
const id = ids[0]; const id = ids[0];
await activityLog.logEntityActivity('list', ListActivityType.CREATE_IMPORT, listId, {importId: id, importStatus: entity.status});
return id; return id;
}); });
@ -148,6 +152,8 @@ async function updateWithConsistencyCheck(context, listId, entity) {
filteredEntity.mapping = JSON.stringify(filteredEntity.mapping); filteredEntity.mapping = JSON.stringify(filteredEntity.mapping);
await tx('imports').where({list: listId, id: entity.id}).update(filteredEntity); await tx('imports').where({list: listId, id: entity.id}).update(filteredEntity);
await activityLog.logEntityActivity('list', ListActivityType.UPDATE_IMPORT, listId, {importId: entity.id, importStatus: entity.status});
}); });
} }
@ -170,6 +176,8 @@ async function removeTx(tx, context, listId, id) {
await tx('import_failed').whereIn('run', function() {this.from('import_runs').select('id').where('import', id)}).del(); await tx('import_failed').whereIn('run', function() {this.from('import_runs').select('id').where('import', id)}).del();
await tx('import_runs').where('import', id).del(); await tx('import_runs').where('import', id).del();
await tx('imports').where({list: listId, id}).del(); await tx('imports').where({list: listId, id}).del();
await activityLog.logEntityActivity('list', ListActivityType.REMOVE_IMPORT, listId, {importId: id});
} }
async function remove(context, listId, id) { async function remove(context, listId, id) {
@ -208,6 +216,8 @@ async function start(context, listId, id) {
status: RunStatus.SCHEDULED, status: RunStatus.SCHEDULED,
mapping: entity.mapping mapping: entity.mapping
}); });
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, listId, {importId: id, importStatus: ImportStatus.RUN_SCHEDULED});
}); });
importer.scheduleCheck(); importer.scheduleCheck();
@ -234,6 +244,8 @@ async function stop(context, listId, id) {
await tx('import_runs').where('import', id).whereIn('status', [RunStatus.SCHEDULED, RunStatus.RUNNING]).update({ await tx('import_runs').where('import', id).whereIn('status', [RunStatus.SCHEDULED, RunStatus.RUNNING]).update({
status: RunStatus.STOPPING status: RunStatus.STOPPING
}); });
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, listId, {importId: id, importStatus: ImportStatus.RUN_STOPPING});
}); });
importer.scheduleCheck(); importer.scheduleCheck();

View file

@ -14,6 +14,9 @@ const imports = require('./imports');
const entitySettings = require('../lib/entity-settings'); const entitySettings = require('../lib/entity-settings');
const dependencyHelpers = require('../lib/dependency-helpers'); const dependencyHelpers = require('../lib/dependency-helpers');
const {EntityActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const {UnsubscriptionMode, FieldWizard} = require('../../shared/lists'); const {UnsubscriptionMode, FieldWizard} = require('../../shared/lists');
const allowedKeys = new Set(['name', 'description', 'default_form', 'public_subscribe', 'unsubscription_mode', 'contact_email', 'homepage', 'namespace', 'to_name', 'listunsubscribe_disabled', 'send_configuration']); const allowedKeys = new Set(['name', 'description', 'default_form', 'public_subscribe', 'unsubscription_mode', 'contact_email', 'homepage', 'namespace', 'to_name', 'listunsubscribe_disabled', 'send_configuration']);
@ -23,16 +26,22 @@ function hash(entity) {
} }
async function listDTAjax(context, params) { async function _listDTAjax(context, namespaceId, params) {
const campaignEntityType = entitySettings.getEntityType('campaign'); const campaignEntityType = entitySettings.getEntityType('campaign');
return await dtHelpers.ajaxListWithPermissions( return await dtHelpers.ajaxListWithPermissions(
context, context,
[{ entityTypeId: 'list', requiredOperations: ['view'] }], [{ entityTypeId: 'list', requiredOperations: ['view'] }],
params, params,
builder => builder builder => {
builder = builder
.from('lists') .from('lists')
.innerJoin('namespaces', 'namespaces.id', 'lists.namespace'), .innerJoin('namespaces', 'namespaces.id', 'lists.namespace');
if (namespaceId) {
builder = builder.where('lists.namespace', namespaceId);
}
return builder;
},
['lists.id', 'lists.name', 'lists.cid', 'lists.subscribers', 'lists.description', 'namespaces.name', ['lists.id', 'lists.name', 'lists.cid', 'lists.subscribers', 'lists.description', 'namespaces.name',
{ {
name: 'triggerCount', name: 'triggerCount',
@ -50,6 +59,14 @@ async function listDTAjax(context, params) {
); );
} }
async function listDTAjax(context, params) {
return await _listDTAjax(context, undefined, params);
}
async function listByNamespaceDTAjax(context, namespaceId, params) {
return await _listDTAjax(context, namespaceId, params);
}
async function listWithSegmentByCampaignDTAjax(context, campaignId, params) { async function listWithSegmentByCampaignDTAjax(context, campaignId, params) {
return await dtHelpers.ajaxListWithPermissions( return await dtHelpers.ajaxListWithPermissions(
context, context,
@ -196,6 +213,8 @@ async function create(context, entity) {
await fields.createTx(tx, context, id, fld); await fields.createTx(tx, context, id, fld);
} }
await activityLog.logEntityActivity('list', EntityActivityType.CREATE, id);
return id; return id;
}); });
} }
@ -221,6 +240,8 @@ async function updateWithConsistencyCheck(context, entity) {
await tx('lists').where('id', entity.id).update(filterObject(entity, allowedKeys)); await tx('lists').where('id', entity.id).update(filterObject(entity, allowedKeys));
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'list', entityId: entity.id }); await shares.rebuildPermissionsTx(tx, { entityTypeId: 'list', entityId: entity.id });
await activityLog.logEntityActivity('list', EntityActivityType.UPDATE, entity.id);
}); });
} }
@ -244,6 +265,8 @@ async function remove(context, id) {
await tx('lists').where('id', id).del(); await tx('lists').where('id', id).del();
await knex.schema.dropTableIfExists('subscription__' + id); await knex.schema.dropTableIfExists('subscription__' + id);
await activityLog.logEntityActivity('list', EntityActivityType.REMOVE, id);
}); });
} }
@ -251,6 +274,7 @@ async function remove(context, id) {
module.exports.UnsubscriptionMode = UnsubscriptionMode; module.exports.UnsubscriptionMode = UnsubscriptionMode;
module.exports.hash = hash; module.exports.hash = hash;
module.exports.listDTAjax = listDTAjax; module.exports.listDTAjax = listDTAjax;
module.exports.listByNamespaceDTAjax = listByNamespaceDTAjax;
module.exports.listWithSegmentByCampaignDTAjax = listWithSegmentByCampaignDTAjax; module.exports.listWithSegmentByCampaignDTAjax = listWithSegmentByCampaignDTAjax;
module.exports.getByIdTx = getByIdTx; module.exports.getByIdTx = getByIdTx;
module.exports.getById = getById; module.exports.getById = getById;

View file

@ -10,6 +10,8 @@ const moment = require('moment');
const fields = require('./fields'); const fields = require('./fields');
const subscriptions = require('./subscriptions'); const subscriptions = require('./subscriptions');
const dependencyHelpers = require('../lib/dependency-helpers'); const dependencyHelpers = require('../lib/dependency-helpers');
const {ListActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const allowedKeys = new Set(['name', 'settings']); const allowedKeys = new Set(['name', 'settings']);
@ -304,6 +306,8 @@ async function create(context, listId, entity) {
const ids = await tx('segments').insert(filteredEntity); const ids = await tx('segments').insert(filteredEntity);
const id = ids[0]; const id = ids[0];
await activityLog.logEntityActivity('list', ListActivityType.CREATE_SEGMENT, listId, {segmentId: id});
return id; return id;
}); });
} }
@ -327,6 +331,8 @@ async function updateWithConsistencyCheck(context, listId, entity) {
await _validateAndPreprocess(tx, listId, entity, false); await _validateAndPreprocess(tx, listId, entity, false);
await tx('segments').where({list: listId, id: entity.id}).update(filterObject(entity, allowedKeys)); await tx('segments').where({list: listId, id: entity.id}).update(filterObject(entity, allowedKeys));
await activityLog.logEntityActivity('list', ListActivityType.UPDATE_SEGMENT, listId, {segmentId: entity.id});
}); });
} }
@ -346,6 +352,8 @@ async function removeTx(tx, context, listId, id) {
// The listId "where" is here to prevent deleting segment of a list for which a user does not have permission // The listId "where" is here to prevent deleting segment of a list for which a user does not have permission
await tx('segments').where({list: listId, id}).del(); await tx('segments').where({list: listId, id}).del();
await activityLog.logEntityActivity('list', ListActivityType.REMOVE_SEGMENT, listId, {segmentId: id});
} }
async function remove(context, listId, id) { async function remove(context, listId, id) {

View file

@ -22,18 +22,32 @@ function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys)); return hasher.hash(filterObject(entity, allowedKeys));
} }
async function listDTAjax(context, params) { async function _listDTAjax(context, namespaceId, params) {
return await dtHelpers.ajaxListWithPermissions( return await dtHelpers.ajaxListWithPermissions(
context, context,
[{ entityTypeId: 'sendConfiguration', requiredOperations: ['viewPublic'] }], [{ entityTypeId: 'sendConfiguration', requiredOperations: ['viewPublic'] }],
params, params,
builder => builder builder => {
builder = builder
.from('send_configurations') .from('send_configurations')
.innerJoin('namespaces', 'namespaces.id', 'send_configurations.namespace'), .innerJoin('namespaces', 'namespaces.id', 'send_configurations.namespace');
if (namespaceId) {
builder = builder.where('send_configurations.namespace', namespaceId);
}
return builder;
},
['send_configurations.id', 'send_configurations.name', 'send_configurations.cid', 'send_configurations.description', 'send_configurations.mailer_type', 'send_configurations.created', 'namespaces.name'] ['send_configurations.id', 'send_configurations.name', 'send_configurations.cid', 'send_configurations.description', 'send_configurations.mailer_type', 'send_configurations.created', 'namespaces.name']
); );
} }
async function listDTAjax(context, params) {
return await _listDTAjax(context, undefined, params);
}
async function listByNamespaceDTAjax(context, namespaceId, params) {
return await _listDTAjax(context, namespaceId, params);
}
async function listWithSendPermissionDTAjax(context, params) { async function listWithSendPermissionDTAjax(context, params) {
return await dtHelpers.ajaxListWithPermissions( return await dtHelpers.ajaxListWithPermissions(
context, context,
@ -175,6 +189,7 @@ async function getSystemSendConfiguration() {
module.exports.hash = hash; module.exports.hash = hash;
module.exports.listDTAjax = listDTAjax; module.exports.listDTAjax = listDTAjax;
module.exports.listByNamespaceDTAjax = listByNamespaceDTAjax;
module.exports.listWithSendPermissionDTAjax = listWithSendPermissionDTAjax; module.exports.listWithSendPermissionDTAjax = listWithSendPermissionDTAjax;
module.exports.getByIdTx = getByIdTx; module.exports.getByIdTx = getByIdTx;
module.exports.getById = getById; module.exports.getById = getById;

View file

@ -10,6 +10,7 @@ const shares = require('./shares');
const reports = require('./reports'); const reports = require('./reports');
const files = require('./files'); const files = require('./files');
const dependencyHelpers = require('../lib/dependency-helpers'); const dependencyHelpers = require('../lib/dependency-helpers');
const {convertFileURLs} = require('../lib/campaign-content');
const allowedKeys = new Set(['name', 'description', 'type', 'data', 'html', 'text', 'namespace']); const allowedKeys = new Set(['name', 'description', 'type', 'data', 'html', 'text', 'namespace']);
@ -35,16 +36,30 @@ async function getById(context, id, withPermissions = true) {
}); });
} }
async function listDTAjax(context, params) { async function _listDTAjax(context, namespaceId, params) {
return await dtHelpers.ajaxListWithPermissions( return await dtHelpers.ajaxListWithPermissions(
context, context,
[{ entityTypeId: 'template', requiredOperations: ['view'] }], [{ entityTypeId: 'template', requiredOperations: ['view'] }],
params, params,
builder => builder.from('templates').innerJoin('namespaces', 'namespaces.id', 'templates.namespace'), builder => {
builder = builder.from('templates').innerJoin('namespaces', 'namespaces.id', 'templates.namespace');
if (namespaceId) {
builder = builder.where('namespaces.id', namespaceId);
}
return builder;
},
[ 'templates.id', 'templates.name', 'templates.description', 'templates.type', 'templates.created', 'namespaces.name' ] [ 'templates.id', 'templates.name', 'templates.description', 'templates.type', 'templates.created', 'namespaces.name' ]
); );
} }
async function listDTAjax(context, params) {
return await _listDTAjax(context, undefined, params);
}
async function listByNamespaceDTAjax(context, namespaceId, params) {
return await _listDTAjax(context, namespaceId, params);
}
async function _validateAndPreprocess(tx, entity) { async function _validateAndPreprocess(tx, entity) {
await namespaceHelpers.validateEntity(tx, entity); await namespaceHelpers.validateEntity(tx, entity);
@ -57,6 +72,15 @@ async function create(context, entity) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createTemplate'); await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createTemplate');
if (entity.fromSourceTemplate) {
const template = await getByIdTx(tx, context, entity.sourceTemplate, false);
entity.type = template.type;
entity.data = template.data;
entity.html = template.html;
entity.text = template.text;
}
await _validateAndPreprocess(tx, entity); await _validateAndPreprocess(tx, entity);
const ids = await tx('templates').insert(filterObject(entity, allowedKeys)); const ids = await tx('templates').insert(filterObject(entity, allowedKeys));
@ -64,6 +88,13 @@ async function create(context, entity) {
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'template', entityId: id }); await shares.rebuildPermissionsTx(tx, { entityTypeId: 'template', entityId: id });
if (entity.fromSourceTemplate) {
await files.copyAllTx(tx, context, 'template', 'file', entity.sourceTemplate, 'template', 'file', id);
convertFileURLs(entity, 'template', entity.sourceTemplate, 'template', id);
await tx('templates').update(filterObject(entity, allowedKeys)).where('id', id);
}
return id; return id;
}); });
} }
@ -118,6 +149,7 @@ module.exports.hash = hash;
module.exports.getByIdTx = getByIdTx; module.exports.getByIdTx = getByIdTx;
module.exports.getById = getById; module.exports.getById = getById;
module.exports.listDTAjax = listDTAjax; module.exports.listDTAjax = listDTAjax;
module.exports.listByNamespaceDTAjax = listByNamespaceDTAjax;
module.exports.create = create; module.exports.create = create;
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck; module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
module.exports.remove = remove; module.exports.remove = remove;

View file

@ -19,6 +19,10 @@ router.postAsync('/campaigns-others-by-list-table/:campaignId/:listIds', passpor
return res.json(await campaigns.listOthersWhoseListsAreIncludedDTAjax(req.context, castToInteger(req.params.campaignId), req.params.listIds.split(';').map(x => castToInteger(x)), req.body)); return res.json(await campaigns.listOthersWhoseListsAreIncludedDTAjax(req.context, castToInteger(req.params.campaignId), req.params.listIds.split(';').map(x => castToInteger(x)), req.body));
}); });
router.postAsync('/campaigns-by-namespace-table/:namespaceId', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.listByNamespaceDTAjax(req.context, castToInteger(req.params.namespaceId), req.body));
});
router.postAsync('/campaigns-children/:campaignId', passport.loggedIn, async (req, res) => { router.postAsync('/campaigns-children/:campaignId', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.listChildrenDTAjax(req.context, castToInteger(req.params.campaignId), req.body)); return res.json(await campaigns.listChildrenDTAjax(req.context, castToInteger(req.params.campaignId), req.body));
}); });

View file

@ -11,6 +11,10 @@ router.postAsync('/lists-table', passport.loggedIn, async (req, res) => {
return res.json(await lists.listDTAjax(req.context, req.body)); return res.json(await lists.listDTAjax(req.context, req.body));
}); });
router.postAsync('/lists-by-namespace-table/:namespaceId', passport.loggedIn, async (req, res) => {
return res.json(await lists.listByNamespaceDTAjax(req.context, castToInteger(req.params.namespaceId), req.body));
});
router.postAsync('/lists-with-segment-by-campaign-table/:campaignId', passport.loggedIn, async (req, res) => { router.postAsync('/lists-with-segment-by-campaign-table/:campaignId', passport.loggedIn, async (req, res) => {
return res.json(await lists.listWithSegmentByCampaignDTAjax(req.context, castToInteger(req.params.campaignId), req.body)); return res.json(await lists.listWithSegmentByCampaignDTAjax(req.context, castToInteger(req.params.campaignId), req.body));
}); });

View file

@ -40,6 +40,10 @@ router.postAsync('/send-configurations-table', passport.loggedIn, async (req, re
return res.json(await sendConfigurations.listDTAjax(req.context, req.body)); return res.json(await sendConfigurations.listDTAjax(req.context, req.body));
}); });
router.postAsync('/send-configurations-by-namespace-table/:namespaceId', passport.loggedIn, async (req, res) => {
return res.json(await sendConfigurations.listByNamespaceDTAjax(req.context, castToInteger(req.params.namespaceId), req.body));
});
router.postAsync('/send-configurations-with-send-permission-table', passport.loggedIn, async (req, res) => { router.postAsync('/send-configurations-with-send-permission-table', passport.loggedIn, async (req, res) => {
return res.json(await sendConfigurations.listWithSendPermissionDTAjax(req.context, req.body)); return res.json(await sendConfigurations.listWithSendPermissionDTAjax(req.context, req.body));
}); });

View file

@ -35,6 +35,10 @@ router.postAsync('/templates-table', passport.loggedIn, async (req, res) => {
return res.json(await templates.listDTAjax(req.context, req.body)); return res.json(await templates.listDTAjax(req.context, req.body));
}); });
router.postAsync('/templates-by-namespace-table/:namespaceId', passport.loggedIn, async (req, res) => {
return res.json(await templates.listByNamespaceDTAjax(req.context, castToInteger(req.params.namespaceId), req.body));
});
router.postAsync('/template-test-send', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/template-test-send', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const data = req.body; const data = req.body;
const result = await CampaignSender.testSend(req.context, data.listCid, data.subscriptionCid, data.campaignId, data.sendConfigurationId, data.html, data.text); const result = await CampaignSender.testSend(req.context, data.listCid, data.subscriptionCid, data.campaignId, data.sendConfigurationId, data.html, data.text);

View file

@ -156,7 +156,6 @@ function getRouter(appType) {
// This is a fallback to versafix-1 if the block thumbnail is not defined by the template // This is a fallback to versafix-1 if the block thumbnail is not defined by the template
router.use('/templates/:mosaicoTemplateId/edres', express.static(path.join(__dirname, '..', '..', 'client', 'static', 'mosaico', 'templates', 'versafix-1', 'edres'))); router.use('/templates/:mosaicoTemplateId/edres', express.static(path.join(__dirname, '..', '..', 'client', 'static', 'mosaico', 'templates', 'versafix-1', 'edres')));
fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', files.ReplacementBehavior.RENAME, null, 'file', resp => { fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', files.ReplacementBehavior.RENAME, null, 'file', resp => {
return { return {
files: resp.files.map(f => ({name: f.name, url: f.url, size: f.size, thumbnailUrl: f.thumbnailUrl})) files: resp.files.map(f => ({name: f.name, url: f.url, size: f.size, thumbnailUrl: f.thumbnailUrl}))

View file

@ -183,7 +183,6 @@ async function _renderSubscribe(req, res, list, subscription) {
const htmlRenderer = await tools.getTemplate(data.template, req.locale); const htmlRenderer = await tools.getTemplate(data.template, req.locale);
data.isWeb = true; data.isWeb = true;
data.needsJsWarning = true;
data.flashMessages = await captureFlashMessages(res); data.flashMessages = await captureFlashMessages(res);
@ -385,7 +384,6 @@ router.getAsync('/:lcid/manage/:ucid', passport.csrfProtection, async (req, res)
const htmlRenderer = await tools.getTemplate(data.template, req.locale); const htmlRenderer = await tools.getTemplate(data.template, req.locale);
data.isWeb = true; data.isWeb = true;
data.needsJsWarning = true;
data.isManagePreferences = true; data.isManagePreferences = true;
data.flashMessages = await captureFlashMessages(res); data.flashMessages = await captureFlashMessages(res);
@ -435,7 +433,6 @@ router.getAsync('/:lcid/manage-address/:ucid', passport.csrfProtection, async (r
const htmlRenderer = await tools.getTemplate(data.template, req.locale); const htmlRenderer = await tools.getTemplate(data.template, req.locale);
data.isWeb = true; data.isWeb = true;
data.needsJsWarning = true;
data.isManagePreferences = true; data.isManagePreferences = true;
data.flashMessages = await captureFlashMessages(res); data.flashMessages = await captureFlashMessages(res);
@ -535,7 +532,6 @@ router.getAsync('/:lcid/unsubscribe/:ucid', passport.csrfProtection, async (req,
const htmlRenderer = await tools.getTemplate(data.template, req.locale); const htmlRenderer = await tools.getTemplate(data.template, req.locale);
data.isWeb = true; data.isWeb = true;
data.needsJsWarning = true;
data.flashMessages = await captureFlashMessages(res); data.flashMessages = await captureFlashMessages(res);
res.send(htmlRenderer(data)); res.send(htmlRenderer(data));
@ -679,7 +675,7 @@ async function webNotice(type, req, res) {
} }
}; };
await injectCustomFormData(req.query.fid || list.default_form, 'web_' + type + '_notice', data); await injectCustomFormData(req.query.fid || list.default_form, 'web_' + type.replace('-', '_') + '_notice', data);
const htmlRenderer = await tools.getTemplate(data.template, req.locale); const htmlRenderer = await tools.getTemplate(data.template, req.locale);

View file

@ -10,8 +10,16 @@ const stringify = require('csv-stringify')
const fields = require('../models/fields'); const fields = require('../models/fields');
const lists = require('../models/lists'); const lists = require('../models/lists');
const moment = require('moment'); const moment = require('moment');
const {SubscriptionStatus} = require('../../shared/lists');
router.getAsync('/export/:listId/:segmentId', passport.loggedIn, async (req, res) => { router.getAsync('/export/:listId/:segmentId', passport.loggedIn, async (req, res) => {
const statusStrings = {
[SubscriptionStatus.SUBSCRIBED]: 'subscribed',
[SubscriptionStatus.UNSUBSCRIBED]: 'unsubscribed',
[SubscriptionStatus.BOUNCED]: 'bounced',
[SubscriptionStatus.COMPLAINED]: 'complained'
};
const listId = castToInteger(req.params.listId); const listId = castToInteger(req.params.listId);
const segmentId = castToInteger(req.params.segmentId); const segmentId = castToInteger(req.params.segmentId);
@ -19,6 +27,7 @@ router.getAsync('/export/:listId/:segmentId', passport.loggedIn, async (req, res
const columns = [ const columns = [
{key: 'cid', header: 'cid'}, {key: 'cid', header: 'cid'},
{key: 'status', header: 'status'},
{key: 'hash_email', header: 'HASH_EMAIL'}, {key: 'hash_email', header: 'HASH_EMAIL'},
{key: 'email', header: 'EMAIL'}, {key: 'email', header: 'EMAIL'},
]; ];
@ -50,6 +59,8 @@ router.getAsync('/export/:listId/:segmentId', passport.loggedIn, async (req, res
stringifier.pipe(res); stringifier.pipe(res);
for await (const subscription of subscriptions.listIterator(req.context, listId, segmentId, false)) { for await (const subscription of subscriptions.listIterator(req.context, listId, segmentId, false)) {
subscription.status = statusStrings[subscription.status];
stringifier.write(subscription); stringifier.write(subscription);
} }

View file

@ -15,6 +15,8 @@ const contextHelpers = require('../lib/context-helpers');
const tools = require('../lib/tools'); const tools = require('../lib/tools');
const shares = require('../models/shares'); const shares = require('../models/shares');
const { tLog } = require('../lib/translate'); const { tLog } = require('../lib/translate');
const {ListActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const csvparse = require('csv-parse'); const csvparse = require('csv-parse');
@ -41,6 +43,8 @@ function prepareCsv(impt) {
error: msg + '\n' + err.message error: msg + '\n' + err.message
}); });
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, impt.list, {importId: impt.id, importStatus: ImportStatus.PREP_FAILED});
await fsExtra.removeAsync(filePath); await fsExtra.removeAsync(filePath);
}; };
@ -56,6 +60,8 @@ function prepareCsv(impt) {
error: null error: null
}); });
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, impt.list, {importId: impt.id, importStatus: ImportStatus.PREP_FINISHED});
await fsExtra.removeAsync(filePath); await fsExtra.removeAsync(filePath);
}; };
@ -263,12 +269,16 @@ async function _execImportRun(impt, handlers) {
status: ImportStatus.RUN_FINISHED status: ImportStatus.RUN_FINISHED
}); });
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, impt.list, {importId: impt.id, importStatus: ImportStatus.RUN_FINISHED});
} catch (err) { } catch (err) {
await knex('imports').where('id', impt.id).update({ await knex('imports').where('id', impt.id).update({
last_run: new Date(), last_run: new Date(),
error: err.message, error: err.message,
status: ImportStatus.RUN_FAILED status: ImportStatus.RUN_FAILED
}); });
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, impt.list, {importId: impt.id, importStatus: ImportStatus.PREP_FAILED});
} }
} }
@ -361,14 +371,20 @@ async function getTask() {
if (impt.source === ImportSource.CSV_FILE && impt.status === ImportStatus.PREP_SCHEDULED) { if (impt.source === ImportSource.CSV_FILE && impt.status === ImportStatus.PREP_SCHEDULED) {
await tx('imports').where('id', impt.id).update('status', ImportStatus.PREP_RUNNING); await tx('imports').where('id', impt.id).update('status', ImportStatus.PREP_RUNNING);
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, impt.list, {importId: impt.id, importStatus: ImportStatus.PREP_RUNNING});
return () => prepareCsv(impt); return () => prepareCsv(impt);
} else if (impt.status === ImportStatus.RUN_SCHEDULED && impt.mapping_type === MappingType.BASIC_SUBSCRIBE) { } else if (impt.status === ImportStatus.RUN_SCHEDULED && impt.mapping_type === MappingType.BASIC_SUBSCRIBE) {
await tx('imports').where('id', impt.id).update('status', ImportStatus.RUN_RUNNING); await tx('imports').where('id', impt.id).update('status', ImportStatus.RUN_RUNNING);
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, impt.list, {importId: impt.id, importStatus: ImportStatus.RUN_RUNNING});
return () => basicSubscribe(impt); return () => basicSubscribe(impt);
} else if (impt.status === ImportStatus.RUN_SCHEDULED && impt.mapping_type === MappingType.BASIC_UNSUBSCRIBE) { } else if (impt.status === ImportStatus.RUN_SCHEDULED && impt.mapping_type === MappingType.BASIC_UNSUBSCRIBE) {
await tx('imports').where('id', impt.id).update('status', ImportStatus.RUN_RUNNING); await tx('imports').where('id', impt.id).update('status', ImportStatus.RUN_RUNNING);
await activityLog.logEntityActivity('list', ListActivityType.IMPORT_STATUS_CHANGE, impt.list, {importId: impt.id, importStatus: ImportStatus.RUN_RUNNING});
return () => basicUnsubscribe(impt); return () => basicUnsubscribe(impt);
} }

View file

@ -9,6 +9,9 @@ const {CampaignStatus, CampaignType} = require('../../shared/campaigns');
const { enforce } = require('../lib/helpers'); const { enforce } = require('../lib/helpers');
const campaigns = require('../models/campaigns'); const campaigns = require('../models/campaigns');
const builtinZoneMta = require('../lib/builtin-zone-mta'); const builtinZoneMta = require('../lib/builtin-zone-mta');
const {CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
let messageTid = 0; let messageTid = 0;
const workerProcesses = new Map(); const workerProcesses = new Map();
@ -127,6 +130,8 @@ async function processCampaign(campaignId) {
} }
await knex('campaigns').where('id', campaignId).update({status: CampaignStatus.FINISHED}); await knex('campaigns').where('id', campaignId).update({status: CampaignStatus.FINISHED});
await activityLog.logEntityActivity('campaign', CampaignActivityType.STATUS_CHANGE, campaignId, {status: CampaignStatus.FINISHED});
messageQueue.delete(campaignId); messageQueue.delete(campaignId);
} }
@ -214,6 +219,7 @@ async function scheduleCampaigns() {
if (scheduledCampaign) { if (scheduledCampaign) {
await tx('campaigns').where('id', scheduledCampaign.id).update({status: CampaignStatus.SENDING}); await tx('campaigns').where('id', scheduledCampaign.id).update({status: CampaignStatus.SENDING});
await activityLog.logEntityActivity('campaign', CampaignActivityType.STATUS_CHANGE, scheduledCampaign.id, {status: CampaignStatus.SENDING});
campaignId = scheduledCampaign.id; campaignId = scheduledCampaign.id;
} }
}); });

View file

@ -11,6 +11,7 @@ defaultLanguage: en-US
# Enabled languages # Enabled languages
enabledLanguages: enabledLanguages:
- en-US - en-US
- es-ES
- fk-FK - fk-FK
mysql: mysql:

View file

@ -526,8 +526,9 @@ async function migrateSegments(knex) {
switch (fieldType) { switch (fieldType) {
case 'text': case 'text':
case 'string':
case 'website': case 'website':
rules.push({ column: oldRule.column, value: oldSettings.value }); rules.push({ type: 'like', column: oldRule.column, value: oldSettings.value });
break; break;
case 'number': case 'number':
if (oldSettings.range) { if (oldSettings.range) {

View file

@ -1,8 +1 @@
{{{flashMessages}}} {{{flashMessages}}}
{{#if needsJsWarning}}
<div class="alert alert-danger js-warning" role="alert">
<strong>{{#translate}}warning!{{/translate}}</strong>
{{#translate}}javaScriptMustBeEnabledInOrderForThis{{/translate}}
</div>
{{/if}}

View file

@ -218,8 +218,10 @@ EOT
function reinstallModules { function reinstallModules {
# Install required node packages # Install required node packages
for idx in client shared server zone-mta mvis/client mvis/server mvis/test-embed mvis/ivis-core/client mvis/ivis-core/server mvis/ivis-core/shared mvis/ivis-core/embedding; do for idx in client shared server zone-mta mvis/client mvis/server mvis/test-embed mvis/ivis-core/client mvis/ivis-core/server mvis/ivis-core/shared mvis/ivis-core/embedding; do
if [ -d $idx ]; then
echo Reinstalling modules in $idx echo Reinstalling modules in $idx
(cd $idx && rm -rf node_modules && npm install) (cd $idx && rm -rf node_modules && npm install)
fi
done done
} }

47
shared/activity-log.js Normal file
View file

@ -0,0 +1,47 @@
'use strict';
const EntityActivityType = {
CREATE: 1,
UPDATE: 2,
REMOVE: 3,
MAX: 3
};
const CampaignActivityType = {
STATUS_CHANGE: EntityActivityType.MAX + 1
};
const ListActivityType = {
CREATE_SUBSCRIPTION: EntityActivityType.MAX + 1,
UPDATE_SUBSCRIPTION: EntityActivityType.MAX + 2,
REMOVE_SUBSCRIPTION: EntityActivityType.MAX + 3,
SUBSCRIPTION_STATUS_CHANGE: EntityActivityType.MAX + 4,
CREATE_FIELD: EntityActivityType.MAX + 5,
UPDATE_FIELD: EntityActivityType.MAX + 6,
REMOVE_FIELD: EntityActivityType.MAX + 7,
CREATE_SEGMENT: EntityActivityType.MAX + 5,
UPDATE_SEGMENT: EntityActivityType.MAX + 6,
REMOVE_SEGMENT: EntityActivityType.MAX + 7,
CREATE_IMPORT: EntityActivityType.MAX + 8,
UPDATE_IMPORT: EntityActivityType.MAX + 9,
REMOVE_IMPORT: EntityActivityType.MAX + 10,
IMPORT_STATUS_CHANGE: EntityActivityType.MAX + 11,
};
const CampaignTrackerActivityType = {
DELIVERED: 1,
BOUNCED: 2,
OPENED: 3,
CLICKED: 4
};
const BlacklistActivityType = {
ADD: 1,
REMOVE: 2
};
module.exports.EntityActivityType = EntityActivityType;
module.exports.BlacklistActivityType = BlacklistActivityType;
module.exports.CampaignActivityType = CampaignActivityType;
module.exports.ListActivityType = ListActivityType;