From ff66a6c39ee41a6d3a6809bbd0754441d4005a0a Mon Sep 17 00:00:00 2001
From: Tomas Bures
Date: Sat, 15 Jun 2019 13:48:14 +0200
Subject: [PATCH 01/29] Helper tool for cleaning node_modules
---
setup/delete-modules.sh | 9 +++++++++
setup/functions | 15 ++++++++++++++-
2 files changed, 23 insertions(+), 1 deletion(-)
create mode 100644 setup/delete-modules.sh
diff --git a/setup/delete-modules.sh b/setup/delete-modules.sh
new file mode 100644
index 00000000..b1519e15
--- /dev/null
+++ b/setup/delete-modules.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+set -e
+
+SCRIPT_PATH=$(dirname $(realpath -s $0))
+. $SCRIPT_PATH/functions
+cd $SCRIPT_PATH/..
+
+deleteAllModules
\ No newline at end of file
diff --git a/setup/functions b/setup/functions
index b84488cb..3399c917 100644
--- a/setup/functions
+++ b/setup/functions
@@ -234,7 +234,6 @@ function reinstallAllModules {
doForAllModules reinstallModules
}
-
function installHttpd {
local portTrusted="$1"
local portSandbox="$2"
@@ -443,3 +442,17 @@ EOT
systemctl daemon-reload
}
+
+
+
+function deleteModules {
+ local idx=$1
+ echo Deleting modules in $idx
+ cd $idx && rm -rf node_modules
+}
+
+function deleteAllModules {
+ doForAllModules deleteModules
+}
+
+
From 30b361290b92c09d7c258345a5042faf5e2a015a Mon Sep 17 00:00:00 2001
From: Tomas Bures
Date: Tue, 25 Jun 2019 07:18:06 +0200
Subject: [PATCH 02/29] - Refactoring of the mail sending part. Mail queue
(table 'queued') is now used also for all test emails. - More options how to
send test emails. - Fixed problems with pausing a campaign (#593) - Started
rework of transactional sender of templates (#606), however this contains
functionality regression at the moment because it does not interpret
templates as HBS. It needs HBS option for templates as described in
https://github.com/Mailtrain-org/mailtrain/issues/611#issuecomment-502345227
TODO:
- detect sending errors connected to not able to contact the mailer and pause/retry campaing and queued sending - don't mark the recipients as BOUNCED
- add FAILED campaign state and fall into it if sending to campaign consistently fails (i.e. the error with sending is not temporary)
- if the same happends for queued email, delete the message
---
client/src/account/API.js | 17 +-
client/src/campaigns/CUD.js | 20 +-
client/src/campaigns/Content.js | 7 +-
client/src/campaigns/Status.js | 105 ++--
client/src/campaigns/TestSendModalDialog.js | 283 +++++++++--
client/src/campaigns/helpers.js | 3 +-
client/src/lib/form.js | 7 +
client/src/send-configurations/CUD.js | 6 +-
client/src/send-configurations/helpers.js | 2 +-
client/src/templates/CUD.js | 3 +-
client/src/templates/TestSendModalDialog.js | 149 ------
locales/en-US/common.json | 4 +-
server/config/default.yaml | 22 +-
server/lib/campaign-sender.js | 448 ++++++++++--------
server/lib/dependency-helpers.js | 3 +-
server/lib/entity-settings.js | 1 +
server/lib/helpers.js | 4 +-
server/lib/mailers.js | 15 +-
server/lib/subscription-mail-helpers.js | 2 +-
server/lib/template-sender.js | 87 ----
server/models/blacklist.js | 17 +-
server/models/campaigns.js | 145 +++++-
server/models/files.js | 59 ++-
server/models/links.js | 34 +-
server/models/send-configurations.js | 4 +-
server/models/subscriptions.js | 29 ++
server/models/templates.js | 68 ++-
server/models/triggers.js | 2 +-
server/models/users.js | 2 +-
server/routes/api.js | 54 +--
server/routes/archive.js | 4 +-
server/routes/rest/campaigns.js | 6 +
server/routes/rest/templates.js | 7 -
server/services/feedcheck.js | 2 +-
server/services/sender-master.js | 362 +++++++++-----
server/services/sender-worker.js | 57 ++-
server/services/triggers.js | 14 +-
.../migrations/20170506102634_v1_to_v2.js | 12 +-
...neralization_of_queued_and_file_locking.js | 63 +++
...000_drop_subject_in_send_configurations.js | 15 +
shared/activity-log.js | 3 +-
shared/campaigns.js | 5 +-
42 files changed, 1366 insertions(+), 786 deletions(-)
delete mode 100644 client/src/templates/TestSendModalDialog.js
delete mode 100644 server/lib/template-sender.js
create mode 100644 server/setup/knex/migrations/20190615000000_generalization_of_queued_and_file_locking.js
create mode 100644 server/setup/knex/migrations/20190616000000_drop_subject_in_send_configurations.js
diff --git a/client/src/account/API.js b/client/src/account/API.js
index beb3f2bc..d3a07a4b 100644
--- a/client/src/account/API.js
+++ b/client/src/account/API.js
@@ -138,7 +138,7 @@ export default class API extends Component {
{t('example')}
- curl -XPOST '{getUrl(`api/subscribe/B16uVTdW?access_token=${accessToken}`)}' \
+ curl -XPOST '{getUrl(`api/subscribe/B16uVTdW?access_token=${accessToken}`)}' \
--data 'EMAIL=test@example.com&MERGE_CHECKBOX=yes&REQUIRE_CONFIRMATION=yes'
POST /api/unsubscribe/:listId – {t('removeSubscription')}
@@ -165,7 +165,7 @@ export default class API extends Component {
{t('example')}
- curl -XPOST '{getUrl(`api/unsubscribe/B16uVTdW?access_token=${accessToken}`)}' \
+ curl -XPOST '{getUrl(`api/unsubscribe/B16uVTdW?access_token=${accessToken}`)}' \
--data 'EMAIL=test@example.com'
POST /api/delete/:listId – {t('deleteSubscription')}
@@ -192,7 +192,7 @@ export default class API extends Component {
{t('example')}
- curl -XPOST '{getUrl(`api/delete/B16uVTdW?access_token=${accessToken}`)}' \
+ curl -XPOST '{getUrl(`api/delete/B16uVTdW?access_token=${accessToken}`)}' \
--data 'EMAIL=test@example.com'
POST /api/field/:listId – {t('addNewCustomField')}
@@ -240,7 +240,7 @@ export default class API extends Component {
{t('example')}
- curl -XPOST '{getUrl(`api/field/B16uVTdW?access_token=${accessToken}`)}' \
+ curl -XPOST '{getUrl(`api/field/B16uVTdW?access_token=${accessToken}`)}' \
--data 'NAME=Birthday&TYPE=birthday-us&VISIBLE=yes'
GET /api/blacklist/get – {t('getListOfBlacklistedEmails')}
@@ -292,7 +292,7 @@ export default class API extends Component {
{t('example')}
- curl -XPOST '{getUrl(`api/blacklist/add?access_token={accessToken}`)}' \
+ curl -XPOST '{getUrl(`api/blacklist/add?access_token={accessToken}`)}' \
--data 'EMAIL=test@example.com&'
POST /api/blacklist/delete – {t('deleteEmailFromBlacklist')}
@@ -319,7 +319,7 @@ export default class API extends Component {
{t('example')}
- curl -XPOST '{getUrl(`api/blacklist/delete?access_token=${accessToken}`)}' \
+ curl -XPOST '{getUrl(`api/blacklist/delete?access_token=${accessToken}`)}' \
--data 'EMAIL=test@example.com&'
GET /api/lists/:email – {t('getTheListsAUserHasSubscribedTo')}
@@ -381,15 +381,14 @@ export default class API extends Component {
EMAIL – {t('emailAddress')} ({t('required')})
SEND_CONFIGURATION_ID – {t('idOfConfigurationUsedToCreateMailer')}
SUBJECT – {t('subject')}
- DATA – {t('dataPassedToTemplateWhenCompilingWith')}: {'{'} "any": ["type", {'{'}"of": "data"{'}'}] {'}'}
- VARIABLES – {t('mapOfTemplatesubjectVariablesToReplace')}: {'{'} "FOO": "bar" {'}'}
+ VARIABLES – {t('mapOfTemplatesubjectVariablesToReplace')}
{t('example')}
- curl -XPOST '{getUrl(`api/templates/1/send?access_token={accessToken}`)}' \
+ curl -XPOST '{getUrl(`api/templates/1/send?access_token=${accessToken}`)}' \
--data 'EMAIL=test@example.com&SUBJECT=Test&VARIABLES[FOO]=bar&VARIABLES[TEST]=example'
);
diff --git a/client/src/campaigns/CUD.js b/client/src/campaigns/CUD.js
index 74585082..37a27284 100644
--- a/client/src/campaigns/CUD.js
+++ b/client/src/campaigns/CUD.js
@@ -244,7 +244,7 @@ export default class CUD extends Component {
return filterData(data, [
'name', 'description', 'segment', 'namespace', 'send_configuration',
- 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override',
+ 'from_name_override', 'from_email_override', 'reply_to_override',
'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url',
'type', 'source', 'parent', 'lists'
]);
@@ -284,6 +284,8 @@ export default class CUD extends Component {
send_configuration: null,
namespace: mailtrainConfig.user.namespace,
+ subject: '',
+
click_tracking_disabled: false,
open_tracking_disabled: false,
@@ -326,6 +328,10 @@ export default class CUD extends Component {
state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
}
+ if (!state.getIn(['subject', 'value'])) {
+ state.setIn(['subject', 'error'], t('"Subject" line must not be empty"'));
+ }
+
if (!state.getIn(['send_configuration', 'value'])) {
state.setIn(['send_configuration', 'error'], t('sendConfigurationMustBeSelected'));
}
@@ -592,7 +598,6 @@ export default class CUD extends Component {
{ data: 2, title: t('id'), render: data => {data}
},
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.mailerTypes[data].typeName },
- { data: 5, title: t('created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('namespace') }
];
@@ -604,10 +609,10 @@ export default class CUD extends Component {
const addOverridable = (id, label) => {
if(this.state.sendConfiguration[id + '_overridable']){
if (this.getFormValue(id + '_overriden')) {
- sendSettings.push();
+ sendSettings.push();
} else {
sendSettings.push(
-
+
{this.state.sendConfiguration[id]}
);
@@ -616,7 +621,7 @@ export default class CUD extends Component {
}
else{
sendSettings.push(
-
+
{this.state.sendConfiguration[id]}
);
@@ -626,7 +631,8 @@ export default class CUD extends Component {
addOverridable('from_name', t('fromName'));
addOverridable('from_email', t('fromEmailAddress'));
addOverridable('reply_to', t('replytoEmailAddress'));
- addOverridable('subject', t('subjectLine'));
+
+ sendSettings.push();
} else {
sendSettings = {t('loadingSendConfiguration')}
}
@@ -760,8 +766,6 @@ export default class CUD extends Component {
>
}
-
-
{templateEdit}
diff --git a/client/src/campaigns/Content.js b/client/src/campaigns/Content.js
index c1e42816..0a2da44c 100644
--- a/client/src/campaigns/Content.js
+++ b/client/src/campaigns/Content.js
@@ -20,7 +20,7 @@ import {getEditForm, getTemplateTypes, getTypeForm, ResourceType} from '../templ
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
-import {TestSendModalDialog} from "./TestSendModalDialog";
+import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
import {withComponentMixins} from "../lib/decorator-helpers";
import {ContentModalDialog} from "../lib/modals";
@@ -234,10 +234,11 @@ export default class CustomContent extends Component {
return (
this.setState({showTestSendModal: false})}
getDataAsync={this.sendModalGetDataHandler}
- entity={this.props.entity}
+ campaign={this.props.entity}
/>
diff --git a/client/src/campaigns/Status.js b/client/src/campaigns/Status.js
index 10d3bf34..5f70fc7f 100644
--- a/client/src/campaigns/Status.js
+++ b/client/src/campaigns/Status.js
@@ -16,6 +16,7 @@ import {CampaignStatus, CampaignType} from "../../../shared/campaigns";
import moment from 'moment';
import campaignsStyles from "./styles.scss";
import {withComponentMixins} from "../lib/decorator-helpers";
+import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
@withComponentMixins([
@@ -25,7 +26,7 @@ import {withComponentMixins} from "../lib/decorator-helpers";
withPageHelpers,
requiresAuthenticatedUser
])
-class TestUser extends Component {
+class PreviewForTestUserModalDialog extends Component {
constructor(props) {
super(props);
this.initForm({
@@ -34,7 +35,9 @@ class TestUser extends Component {
}
static propTypes = {
- entity: PropTypes.object.isRequired
+ visible: PropTypes.bool.isRequired,
+ onHide: PropTypes.func.isRequired,
+ entity: PropTypes.object.isRequired,
}
localValidateFormValues(state) {
@@ -64,6 +67,10 @@ class TestUser extends Component {
}
}
+ async hideModal() {
+ this.props.onHide();
+ }
+
render() {
const t = this.props.t;
@@ -76,12 +83,14 @@ class TestUser extends Component {
];
return (
-
+ this.hideModal()} buttons={[
+ { label: t('preview'), className: 'btn-primary', onClickAsync: ::this.previewAsync },
+ { label: t('close'), className: 'btn-danger', onClickAsync: ::this.hideModal }
+ ]}>
+
+
);
}
}
@@ -96,6 +105,12 @@ class TestUser extends Component {
class SendControls extends Component {
constructor(props) {
super(props);
+
+ this.state = {
+ showTestSendModal: false,
+ previewForTestUserVisible: false
+ };
+
this.initForm({
leaveConfirmation: false
});
@@ -257,13 +272,33 @@ class SendControls extends Component {
const t = this.props.t;
const entity = this.props.entity;
- const yesNoDialog = (
- this.modalAction(false)} buttons={[
- { label: t('no'), className: 'btn-primary', onClickAsync: () => this.modalAction(false) },
- { label: t('yes'), className: 'btn-danger', onClickAsync: () => this.modalAction(true) }
- ]}>
- {this.state.modalMessage}
-
+ const dialogs = (
+ <>
+ this.setState({showTestSendModal: false})}
+ campaign={this.props.entity}
+ />
+ this.setState({previewForTestUserVisible: false})}
+ entity={this.props.entity}
+ />
+ this.modalAction(false)} buttons={[
+ { label: t('no'), className: 'btn-primary', onClickAsync: () => this.modalAction(false) },
+ { label: t('yes'), className: 'btn-danger', onClickAsync: () => this.modalAction(true) }
+ ]}>
+ {this.state.modalMessage}
+
+ >
+ );
+
+ const testButtons = (
+ <>
+ this.setState({previewForTestUserVisible: true})}/>
+ this.setState({showTestSendModal: true})}/>
+ >
);
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
@@ -271,7 +306,7 @@ class SendControls extends Component {
const subscrInfo = entity.subscriptionsToSend === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return (
- {yesNoDialog}
+
{dialogs}
{entity.scheduled ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
@@ -292,20 +327,36 @@ class SendControls extends Component {
:
}
+ {entity.status === CampaignStatus.PAUSED && }
{entity.status === CampaignStatus.PAUSED && }
+ {testButtons}
+
+
+ );
+
+ } else if (entity.status === CampaignStatus.PAUSING) {
+ return (
+
{dialogs}
+
+ {t('Campaign is being paused. Please wait.')}
+
+
+
+ {testButtons}
);
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
return (
-
{yesNoDialog}
+
{dialogs}
{t('campaignIsBeingSentOut')}
+ {testButtons}
);
@@ -314,7 +365,7 @@ class SendControls extends Component {
const subscrInfo = entity.subscriptionsToSend === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return (
-
{yesNoDialog}
+
{dialogs}
{t('allMessagesSent!HitContinueIfYouYouWant')}
@@ -322,35 +373,38 @@ class SendControls extends Component {
+ {testButtons}
);
} else if (entity.status === CampaignStatus.INACTIVE) {
return (
-
{yesNoDialog}
+
{dialogs}
{t('yourCampaignIsCurrentlyDisabledClick')}
+ {testButtons}
);
} else if (entity.status === CampaignStatus.ACTIVE) {
return (
-
{yesNoDialog}
+
{dialogs}
{t('yourCampaignIsEnabledAndSendingMessages')}
+ {testButtons}
);
- } else {
+ } else {
return null;
}
}
@@ -441,7 +495,7 @@ export default class Status extends Component {
addOverridable('from_name', t('fromName'));
addOverridable('from_email', t('fromEmailAddress'));
addOverridable('reply_to', t('replytoEmailAddress'));
- addOverridable('subject', t('subjectLine'));
+ sendSettings.push(
{entity.subject});
} else {
sendSettings =
{t('loadingSendConfiguration')}
}
@@ -491,13 +545,6 @@ export default class Status extends Component {
- {(entity.type === CampaignType.REGULAR || entity.type === CampaignType.TRIGGERED) &&
-
-
-
-
- }
-
diff --git a/client/src/campaigns/TestSendModalDialog.js b/client/src/campaigns/TestSendModalDialog.js
index c1f5d378..894c612e 100644
--- a/client/src/campaigns/TestSendModalDialog.js
+++ b/client/src/campaigns/TestSendModalDialog.js
@@ -5,13 +5,26 @@ import {withTranslation} from '../lib/i18n';
import PropTypes from 'prop-types';
import {ModalDialog} from "../lib/bootstrap-components";
import {requiresAuthenticatedUser, withPageHelpers} from "../lib/page";
-import {Form, TableSelect, withForm} from "../lib/form";
+import {CheckBox, Dropdown, Form, InputField, TableSelect, withForm} from "../lib/form";
import {withErrorHandling} from "../lib/error-handling";
import {getMailerTypes} from "../send-configurations/helpers";
import axios from '../lib/axios';
import {getUrl} from '../lib/urls';
import {withComponentMixins} from "../lib/decorator-helpers";
+import {CampaignType} from "../../../shared/campaigns";
+const Target = {
+ CAMPAIGN_ONE: 'campaign_one',
+ CAMPAIGN_ALL: 'campaign_all',
+ LIST_ONE: 'list_one',
+ LIST_ALL: 'list_all'
+};
+
+export const TestSendModalDialogMode = {
+ TEMPLATE: 0,
+ CAMPAIGN_CONTENT: 1,
+ CAMPAIGN_STATUS: 2
+}
@withComponentMixins([
withTranslation,
@@ -27,21 +40,39 @@ export class TestSendModalDialog extends Component {
this.mailerTypes = getMailerTypes(props.t);
this.initForm({
- leaveConfirmation: false
+ leaveConfirmation: false,
+ onChangeBeforeValidation: {
+ list: this.onListChanged
+ }
});
}
static propTypes = {
- stateOwner: PropTypes.object,
visible: PropTypes.bool.isRequired,
+ mode: PropTypes.number.isRequired,
onHide: PropTypes.func.isRequired,
- getDataAsync: PropTypes.func.isRequired,
- entity: PropTypes.object
+ getDataAsync: PropTypes.func,
+ campaign: PropTypes.object
+ }
+
+ onListChanged(mutStateData, key, oldValue, newValue) {
+ mutStateData.setIn(['segment', 'value'], null);
}
componentDidMount() {
+ const t = this.props.t;
+
this.populateFormValues({
- testUser: null,
+ target: Target.CAMPAIGN_ONE,
+ testUserSubscriptionCid: null,
+ testUserListAndSubscriptionCid: null,
+ subjectPrepend: '',
+ subjectAppend: t(' [Test]'),
+ sendConfiguration: null,
+ listCid: null,
+ list: null,
+ segment: null,
+ useSegmentation: false
});
}
@@ -52,25 +83,49 @@ export class TestSendModalDialog extends Component {
async performAction() {
const props = this.props;
const t = props.t;
+ const mode = this.props.mode;
if (this.isFormWithoutErrors()) {
-
try {
this.hideFormValidation();
this.disableForm();
this.setFormStatusMessage('info', t('sendingTestEmail'));
- const data = await this.props.getDataAsync();
+ const data = {};
- const campaignCid = props.entity.cid;
- const [listCid, subscriptionCid] = this.getFormValue('testUser').split(':');
+ if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.TEMPLATE) {
+ const contentData = await this.props.getDataAsync();
+ data.html = contentData.html;
+ data.text = contentData.text;
+ }
- data.listCid = listCid;
- data.subscriptionCid = subscriptionCid;
- data.sendConfigurationId = props.entity.send_configuration;
- data.campaignId = props.entity.id;
+ if (mode === TestSendModalDialogMode.TEMPLATE) {
+ data.listCid = this.getFormValue('listCid');
+ data.subscriptionCid = this.getFormValue('testUserSubscriptionCid');
+ data.sendConfigurationId = this.getFormValue('sendConfiguration');
- await axios.post(getUrl('rest/template-test-send'), data);
+ } else if (mode === TestSendModalDialogMode.CAMPAIGN_STATUS || mode === TestSendModalDialogMode.CAMPAIGN_CONTENT) {
+ data.campaignId = props.campaign.id;
+ data.subjectPrepend = this.getFormValue('subjectPrepend');
+ data.subjectAppend = this.getFormValue('subjectAppend');
+
+ const target = this.getFormValue('target');
+ if (target === Target.CAMPAIGN_ONE) {
+ const [listCid, subscriptionCid] = this.getFormValue('testUserListAndSubscriptionCid').split(':');
+ data.listCid = listCid;
+ data.subscriptionCid = subscriptionCid;
+
+ } else if (target === Target.LIST_ALL) {
+ data.listId = this.getFormValue('list');
+ data.segmentId = this.getFormValue('useSegmentation') ? this.getFormValue('segment') : null;
+
+ } else if (target === Target.LIST_ONE) {
+ data.listCid = this.getFormValue('listCid');
+ data.subscriptionCid = this.getFormValue('testUserSubscriptionCid');
+ }
+ }
+
+ await axios.post(getUrl('rest/campaign-test-send'), data);
this.clearFormStatusMessage();
@@ -87,32 +142,204 @@ export class TestSendModalDialog extends Component {
localValidateFormValues(state) {
const t = this.props.t;
+ const props = this.props;
+ const target = this.getFormValue('target');
+ const mode = this.props.mode;
- if (!state.getIn(['testUser', 'value'])) {
- state.setIn(['testUser', 'error'], t('subscriptionHasToBeSelected'))
- } else {
- state.setIn(['testUser', 'error'], null);
+ state.setIn(['listCid', 'error'], null);
+ state.setIn(['sendConfiguration', 'error'], null);
+ state.setIn(['testUserSubscriptionCid', 'error'], null);
+ state.setIn(['testUserListAndSubscriptionCid', 'error'], null);
+ state.setIn(['list', 'error'], null);
+ state.setIn(['segment', 'error'], null);
+
+ if (mode === TestSendModalDialogMode.TEMPLATE) {
+ if (!state.getIn(['listCid', 'value'])) {
+ state.setIn(['listCid', 'error'], t('listHasToBeSelected'))
+ }
+
+ if (!state.getIn(['sendConfiguration', 'value'])) {
+ state.setIn(['sendConfiguration', 'error'], t('sendConfigurationHasToBeSelected'))
+ }
+
+ if (!state.getIn(['testUserSubscriptionCid', 'value'])) {
+ state.setIn(['testUserSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
+ }
+ }
+
+ if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.CAMPAIGN_ONE) {
+ if (!state.getIn(['testUserListAndSubscriptionCid', 'value'])) {
+ state.setIn(['testUserListAndSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
+ }
+ }
+
+ if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ONE) {
+ if (!state.getIn(['listCid', 'value'])) {
+ state.setIn(['listCid', 'error'], t('listHasToBeSelected'))
+ }
+
+ if (!state.getIn(['testUserSubscriptionCid', 'value'])) {
+ state.setIn(['testUserSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
+ }
+ }
+
+ if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ALL) {
+ if (!state.getIn(['list', 'value'])) {
+ state.setIn(['list', 'error'], t('listMustBeSelected'));
+ }
+
+ if (state.getIn(['useSegmentation', 'value']) && !state.getIn(['segment', 'value'])) {
+ state.setIn(['segment', 'error'], t('segmentMustBeSelected'));
+ }
}
}
render() {
const t = this.props.t;
+ const props = this.props;
- const testUsersColumns = [
- { data: 1, title: t('email') },
- { data: 2, title: t('subscriptionId'), render: data => {data}
},
- { data: 3, title: t('listId'), render: data => {data}
},
- { data: 4, title: t('list') },
- { data: 5, title: t('listNamespace') }
- ];
+ const content = [];
+ const target = this.getFormValue('target');
+ const mode = this.props.mode;
+
+ if (mode === TestSendModalDialogMode.CAMPAIGN_STATUS) {
+ const targetOpts = [
+ {key: Target.CAMPAIGN_ONE, label: t('Single test user of the campaign')},
+ {key: Target.CAMPAIGN_ALL, label: t('All test users of the campaign')},
+ {key: Target.LIST_ONE, label: t('Single test user from a list')},
+ {key: Target.LIST_ALL, label: t('All test users from a list/segment')}
+ ];
+
+ content.push(
+
+ );
+ }
+
+ if (mode === TestSendModalDialogMode.TEMPLATE) {
+ const listCid = this.getFormValue('listCid');
+
+ const testUsersColumns = [
+ { data: 1, title: t('subscriptionId'), render: data => {data}
},
+ { data: 2, title: t('email') }
+ ];
+
+ const listsColumns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('id'), render: data => {data}
},
+ { data: 3, title: t('subscribers') },
+ { data: 4, title: t('description') },
+ { data: 5, title: t('namespace') }
+ ];
+
+ const sendConfigurationsColumns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('id'), render: data => {data}
},
+ { data: 3, title: t('description') },
+ { data: 4, title: t('type'), render: data => this.mailerTypes[data].typeName },
+ { data: 6, title: t('namespace') }
+ ];
+
+ content.push(
+
+ );
+
+ content.push(
+
+ );
+
+ if (listCid) {
+ content.push(
+
+ );
+ }
+ }
+
+ if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.CAMPAIGN_ONE) {
+ const testUsersColumns = [
+ {data: 1, title: t('email')},
+ {data: 2, title: t('subscriptionId'), render: data => {data}
},
+ {data: 3, title: t('listId'), render: data => {data}
},
+ {data: 4, title: t('list')},
+ {data: 5, title: t('listNamespace')}
+ ];
+
+ content.push(
+
+ );
+ }
+
+ if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ONE) {
+ const listCid = this.getFormValue('listCid');
+
+ const listsColumns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('id'), render: data => {data}
},
+ { data: 3, title: t('subscribers') },
+ { data: 4, title: t('description') },
+ { data: 5, title: t('namespace') }
+ ];
+
+ const testUsersColumns = [
+ { data: 1, title: t('subscriptionId'), render: data => {data}
},
+ { data: 2, title: t('email') }
+ ];
+
+ content.push(
+
+ );
+
+ if (listCid) {
+ content.push(
+
+ );
+ }
+ }
+
+ if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ALL) {
+ const listsColumns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('id'), render: data => {data}
},
+ { data: 3, title: t('subscribers') },
+ { data: 4, title: t('description') },
+ { data: 5, title: t('namespace') }
+ ];
+
+ const segmentsColumns = [
+ { data: 1, title: t('name') }
+ ];
+
+ content.push(
+
+ );
+
+ const selectedList = this.getFormValue('list');
+ content.push(
+
+
+ {selectedList && this.getFormValue('useSegmentation') &&
+
+ }
+
+ );
+ }
+
+ if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) {
+ content.push(
+
+ );
+
+ content.push(
+
+ );
+ }
return (
this.hideModal()} buttons={[
{ label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
- { label: t('cancel'), className: 'btn-danger', onClickAsync: ::this.hideModal }
+ { label: t('close'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}>
);
diff --git a/client/src/campaigns/helpers.js b/client/src/campaigns/helpers.js
index b0ac0be3..48bdbd4b 100644
--- a/client/src/campaigns/helpers.js
+++ b/client/src/campaigns/helpers.js
@@ -18,7 +18,8 @@ export function getCampaignLabels(t) {
[CampaignStatus.PAUSED]: t('paused'),
[CampaignStatus.INACTIVE]: t('inactive'),
[CampaignStatus.ACTIVE]: t('active'),
- [CampaignStatus.SENDING]: t('sending')
+ [CampaignStatus.SENDING]: t('sending'),
+ [CampaignStatus.PAUSING]: t('Pausing')
};
diff --git a/client/src/lib/form.js b/client/src/lib/form.js
index a0c5011e..73c9baf0 100644
--- a/client/src/lib/form.js
+++ b/client/src/lib/form.js
@@ -333,6 +333,13 @@ class InputField extends Component {
const className = owner.addFormValidationClass('form-control', id);
+ /* This is for debugging purposes when React reports that InputField is uncontrolled
+ const value = owner.getFormValue(id);
+ if (value === null || value === undefined) console.log(`Warning: InputField ${id} is ${value}`);
+ */
+ const value = owner.getFormValue(id);
+ if (value === null || value === undefined) console.log(`Warning: InputField ${id} is ${value}`);
+
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
owner.updateFormValue(id, evt.target.value)}/>
);
diff --git a/client/src/send-configurations/CUD.js b/client/src/send-configurations/CUD.js
index 97687f13..074d9973 100644
--- a/client/src/send-configurations/CUD.js
+++ b/client/src/send-configurations/CUD.js
@@ -85,7 +85,7 @@ export default class CUD extends Component {
}
return filterData(data, ['name', 'description', 'from_email', 'from_email_overridable', 'from_name',
- 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'subject', 'subject_overridable', 'x_mailer',
+ 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'x_mailer',
'verp_hostname', 'verp_disable_sender_header', 'mailer_type', 'mailer_settings', 'namespace']);
}
@@ -103,8 +103,6 @@ export default class CUD extends Component {
from_name_overridable: false,
reply_to: '',
reply_to_overridable: false,
- subject: '',
- subject_overridable: false,
verpEnabled: false,
verp_hostname: '',
verp_disable_sender_header: false,
@@ -233,8 +231,6 @@ export default class CUD extends Component {
-
-
diff --git a/client/src/send-configurations/helpers.js b/client/src/send-configurations/helpers.js
index ea9f2235..e78ccfbf 100644
--- a/client/src/send-configurations/helpers.js
+++ b/client/src/send-configurations/helpers.js
@@ -21,7 +21,7 @@ export function getMailerTypes(t) {
const initVals = mailerTypes[mailerType].initData();
for (const key in initVals) {
- if (!mutStateData.hasIn([key])) {
+ if (!mutStateData.hasIn([key, 'value']) || mutStateData.getIn([key, 'value']) === undefined) {
mutStateData.setIn([key, 'value'], initVals[key]);
}
}
diff --git a/client/src/templates/CUD.js b/client/src/templates/CUD.js
index 7129b86b..dd9f5029 100644
--- a/client/src/templates/CUD.js
+++ b/client/src/templates/CUD.js
@@ -27,7 +27,7 @@ import {getEditForm, getTemplateTypes, getTypeForm} from './helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
-import {TestSendModalDialog} from "./TestSendModalDialog";
+import {TestSendModalDialog, TestSendModalDialogMode} from "../campaigns/TestSendModalDialog";
import {withComponentMixins} from "../lib/decorator-helpers";
import moment from 'moment';
@@ -298,6 +298,7 @@ export default class CUD extends Component {
{isEdit &&
this.setState({showTestSendModal: false})}
getDataAsync={this.sendModalGetDataHandler}/>
diff --git a/client/src/templates/TestSendModalDialog.js b/client/src/templates/TestSendModalDialog.js
deleted file mode 100644
index 5d72d65a..00000000
--- a/client/src/templates/TestSendModalDialog.js
+++ /dev/null
@@ -1,149 +0,0 @@
-'use strict';
-
-import React, {Component} from 'react';
-import {withTranslation} from '../lib/i18n';
-import PropTypes from 'prop-types';
-import {ModalDialog} from "../lib/bootstrap-components";
-import {requiresAuthenticatedUser, withPageHelpers} from "../lib/page";
-import {Form, TableSelect, withForm} from "../lib/form";
-import {withErrorHandling} from "../lib/error-handling";
-import moment from "moment";
-import {getMailerTypes} from "../send-configurations/helpers";
-import axios from '../lib/axios';
-import {getUrl} from "../lib/urls";
-import {withComponentMixins} from "../lib/decorator-helpers";
-
-
-@withComponentMixins([
- withTranslation,
- withForm,
- withErrorHandling,
- withPageHelpers,
- requiresAuthenticatedUser
-])
-export class TestSendModalDialog extends Component {
- constructor(props) {
- super(props);
-
- this.mailerTypes = getMailerTypes(props.t);
-
- this.initForm({
- leaveConfirmation: false
- });
- }
-
- static propTypes = {
- stateOwner: PropTypes.object,
- visible: PropTypes.bool.isRequired,
- onHide: PropTypes.func.isRequired,
- getDataAsync: PropTypes.func.isRequired
- }
-
- componentDidMount() {
- this.populateFormValues({
- list: null,
- testUser: null,
- sendConfiguration: null
- });
- }
-
- async hideModal() {
- this.props.onHide();
- }
-
- async performAction() {
- const props = this.props;
- const t = props.t;
-
- if (this.isFormWithoutErrors()) {
-
- try {
- this.hideFormValidation();
- this.disableForm();
- this.setFormStatusMessage('info', t('sendingTestEmail'));
-
- const data = await this.props.getDataAsync();
- data.listCid = this.getFormValue('list');
- data.subscriptionCid = this.getFormValue('testUser');
- data.sendConfigurationId = this.getFormValue('sendConfiguration');
-
- await axios.post(getUrl('rest/template-test-send'), data);
-
- this.clearFormStatusMessage();
-
- this.enableForm();
- await this.hideModal();
-
- } catch (err) {
- throw err;
- }
- } else {
- this.showFormValidation();
- }
- }
-
- localValidateFormValues(state) {
- const t = this.props.t;
-
- if (!state.getIn(['sendConfiguration', 'value'])) {
- state.setIn(['sendConfiguration', 'error'], t('sendConfigurationHasToBeSelected'))
- } else {
- state.setIn(['sendConfiguration', 'error'], null);
- }
-
- if (!state.getIn(['list', 'value'])) {
- state.setIn(['list', 'error'], t('listHasToBeSelected'))
- } else {
- state.setIn(['list', 'error'], null);
- }
-
- if (!state.getIn(['testUser', 'value'])) {
- state.setIn(['testUser', 'error'], t('subscriptionHasToBeSelected'))
- } else {
- state.setIn(['testUser', 'error'], null);
- }
- }
-
- render() {
- const t = this.props.t;
-
- const listId = this.getFormValue('list');
-
- const testUsersColumns = [
- { data: 1, title: t('subscriptionId'), render: data => {data}
},
- { data: 2, title: t('email') }
- ];
-
- const listsColumns = [
- { data: 1, title: t('name') },
- { data: 2, title: t('id'), render: data => {data}
},
- { data: 3, title: t('subscribers') },
- { data: 4, title: t('description') },
- { data: 5, title: t('namespace') }
- ];
-
- const sendConfigurationsColumns = [
- { data: 1, title: t('name') },
- { data: 2, title: t('id'), render: data => {data}
},
- { data: 3, title: t('description') },
- { data: 4, title: t('type'), render: data => this.mailerTypes[data].typeName },
- { data: 5, title: t('created'), render: data => moment(data).fromNow() },
- { data: 6, title: t('namespace') }
- ];
-
- return (
- this.hideModal()} buttons={[
- { label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
- { label: t('cancel'), className: 'btn-danger', onClickAsync: ::this.hideModal }
- ]}>
-
-
- );
- }
-}
diff --git a/locales/en-US/common.json b/locales/en-US/common.json
index dbc647da..467f1111 100644
--- a/locales/en-US/common.json
+++ b/locales/en-US/common.json
@@ -76,10 +76,10 @@
"forcesTheRssFeedCheckToImmediatelyCheck": "Forces the RSS feed check to immediately check the campaign with the given CID (in :campaignCid). It works only for RSS campaigns.",
"sendTransactionalEmail": "Send transactional email",
"sendSingleEmailByTemplateWithGiven": "Send single email by template with given templateId",
- "idOfConfigurationUsedToCreateMailer": "ID of configuration used to create mailer instance",
+ "idOfConfigurationUsedToCreateMailer": "ID of configuration used to create mailer instance. If omitted, the default system send configuration is used.",
"subject": "Subject",
"dataPassedToTemplateWhenCompilingWith": "Data passed to template when compiling with Handlebars",
- "mapOfTemplatesubjectVariablesToReplace": "Map of template/subject variables to replace",
+ "mapOfTemplatesubjectVariablesToReplace": "Map of template variables to replace",
"apiResponseIsAJsonStructureWithErrorAnd": "API response is a JSON structure with <1>error1> and <3>data3> properties. If the response <5>error5> has a value set then the request failed.",
"youNeedToDefineProperContentTypeWhen": "You need to define proper <1>Content-Type1> when making a request. You can either use <3>application/x-www-form-urlencoded3> for normal form data or <5>application/json5> for a JSON payload. Using <7>multipart/form-data7> is not supported.",
"emailMustNotBeEmpty-1": "Email must not be empty",
diff --git a/server/config/default.yaml b/server/config/default.yaml
index 59800d5c..6b229ab9 100644
--- a/server/config/default.yaml
+++ b/server/config/default.yaml
@@ -260,9 +260,9 @@ roles:
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createReportTemplate, createTemplate, createMosaicoTemplate, createSendConfiguration, createCampaign, manageUsers]
children:
sendConfiguration: [viewPublic, viewPrivate, edit, delete, share, sendWithoutOverrides, sendWithAllowedOverrides, sendWithAnyOverrides]
- list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports]
+ list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports, send, sendToTestUsers]
customForm: [view, edit, delete, share]
- campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, viewStats, fetchRss]
+ campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, sendToTestUsers, viewStats, fetchRss]
template: [view, edit, delete, share, viewFiles, manageFiles]
report: [view, edit, delete, share, execute, viewContent, viewOutput]
reportTemplate: [view, edit, delete, share, execute]
@@ -275,9 +275,9 @@ roles:
permissions: [view, edit, delete, share, createNamespace, createList, createCustomForm, createReport, createTemplate, createMosaicoTemplate, createCampaign]
children:
sendConfiguration: [viewPublic, sendWithoutOverrides, sendWithAllowedOverrides]
- list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports]
+ list: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports, send, sendToTestUsers]
customForm: [view, edit, delete, share]
- campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, viewStats, fetchRss]
+ campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, sendToTestUsers, viewStats, fetchRss]
template: [view, edit, delete, share, viewFiles, manageFiles]
report: [view, edit, delete, share, execute, viewContent, viewOutput]
reportTemplate: [view, share, execute]
@@ -286,11 +286,11 @@ roles:
campaignsCreator:
name: Campaigns Creator
- description: In the respective namespace, the user has all permissions for templates and campaigns.
+ description: In the respective namespace, the user has all permissions to create and manage templates and campaigns.
permissions: [view, createTemplate, createCampaign]
children:
sendConfiguration: [viewPublic]
- campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, fetchRss]
+ campaign: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, sendToTestUsers, fetchRss]
template: [view, edit, delete, share, viewFiles, manageFiles]
sendConfiguration:
@@ -307,11 +307,11 @@ roles:
master:
name: Master
description: All permissions
- permissions: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports]
+ permissions: [view, edit, delete, share, viewFields, manageFields, viewSubscriptions, manageSubscriptions, viewSegments, manageSegments, viewImports, manageImports, send, sendToTestUsers]
campaignsCreator:
name: Campaigns Creator
- description: The user can only use the list in setting up a campaign. However, this gives no permission to view subscriptions or to send to the list.
- permissions: [view, viewFields, viewSegments]
+ description: The user can only use the list in setting up a campaign and to send email to test users. This gives no permission to view subscriptions or to send to the whole list.
+ permissions: [view, viewFields, viewSegments, sendToTestUsers]
customForm:
master:
@@ -323,11 +323,11 @@ roles:
master:
name: Master
description: All permissions
- permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, viewStats, manageMessages, fetchRss]
+ permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, send, sendToTestUsers, viewStats, manageMessages, fetchRss]
campaignsCreator:
name: Campaigns Creator
description: The user can setup the campaign but cannot send it.
- permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, fetchRss]
+ permissions: [view, edit, delete, share, viewFiles, manageFiles, viewAttachments, manageAttachments, viewTriggers, manageTriggers, sendToTestUsers, fetchRss]
rssTrigger:
name: RSS Campaign Trigger
description: Allows triggering a fetch of an RSS campaign
diff --git a/server/lib/campaign-sender.js b/server/lib/campaign-sender.js
index a0d7a44c..380b9bdc 100644
--- a/server/lib/campaign-sender.js
+++ b/server/lib/campaign-sender.js
@@ -21,215 +21,163 @@ const htmlToText = require('html-to-text');
const {getPublicUrl} = require('./urls');
const blacklist = require('../models/blacklist');
const libmime = require('libmime');
-const shares = require('../models/shares');
+const { enforce } = require('./helpers');
+
+const MessageType = {
+ REGULAR: 0,
+ TRIGGERED: 1,
+ TEST: 2
+};
class CampaignSender {
constructor() {
}
- static async testSend(context, listCid, subscriptionCid, campaignId, sendConfigurationId, html, text) {
- let sendConfiguration, list, fieldsGrouped, campaign, subscriptionGrouped, useVerp, useVerpSenderHeader, mergeTags, attachments;
-
- await knex.transaction(async tx => {
- sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), sendConfigurationId, false, true);
- list = await lists.getByCidTx(tx, context, listCid);
- fieldsGrouped = await fields.listGroupedTx(tx, list.id);
-
- useVerp = config.verp.enabled && sendConfiguration.verp_hostname;
- useVerpSenderHeader = useVerp && !sendConfiguration.verp_disable_sender_header;
-
- subscriptionGrouped = await subscriptions.getByCid(context, list.id, subscriptionCid);
- mergeTags = fields.getMergeTags(fieldsGrouped, subscriptionGrouped);
-
- if (campaignId) {
- campaign = await campaigns.getByIdTx(tx, context, campaignId, false, campaigns.Content.WITHOUT_SOURCE_CUSTOM);
- await campaigns.enforceSendPermissionTx(tx, context, campaign);
- } else {
- await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', sendConfigurationId, 'sendWithoutOverrides');
-
- // This is to fake the campaign for getMessageLinks, which is called inside formatMessage
- campaign = {
- cid: '[CAMPAIGN_ID]'
- };
- }
- });
-
- const encryptionKeys = [];
- for (const fld of fieldsGrouped) {
- if (fld.type === 'gpg' && mergeTags[fld.key]) {
- encryptionKeys.push(mergeTags[fld.key].trim());
- }
- }
-
- attachments = [];
- // replace data: images with embedded attachments
- html = html.replace(/(
]* src\s*=[\s"']*)(data:[^"'>\s]+)/gi, (match, prefix, dataUri) => {
- const cid = shortid.generate() + '-attachments';
- attachments.push({
- path: dataUri,
- cid
- });
- return prefix + 'cid:' + cid;
- });
-
- html = tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, html, true);
-
- text = (text || '').trim()
- ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, text)
- : htmlToText.fromString(html, {wordwrap: 130});
-
-
- const mailer = await mailers.getOrCreateMailer(sendConfiguration.id);
-
- const getOverridable = key => {
- return sendConfiguration[key];
- };
-
- const campaignAddress = [campaign.cid, list.cid, subscriptionGrouped.cid].join('.');
-
- const mail = {
- from: {
- name: getOverridable('from_name'),
- address: getOverridable('from_email')
- },
- replyTo: getOverridable('reply_to'),
- xMailer: sendConfiguration.x_mailer ? sendConfiguration.x_mailer : false,
- to: {
- name: list.to_name === null ? undefined : tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false),
- address: subscriptionGrouped.email
- },
- sender: useVerpSenderHeader ? campaignAddress + '@' + sendConfiguration.verp_hostname : false,
-
- envelope: useVerp ? {
- from: campaignAddress + '@' + sendConfiguration.verp_hostname,
- to: subscriptionGrouped.email
- } : false,
-
- headers: {
- 'x-fbl': campaignAddress,
- // custom header for SparkPost
- 'x-msys-api': JSON.stringify({
- campaign_id: campaignAddress
- }),
- // custom header for SendGrid
- 'x-smtpapi': JSON.stringify({
- unique_args: {
- campaign_id: campaignAddress
- }
- }),
- // custom header for Mailgun
- 'x-mailgun-variables': JSON.stringify({
- campaign_id: campaignAddress
- }),
- 'List-ID': {
- prepared: true,
- value: libmime.encodeWords(list.name) + ' <' + list.cid + '.' + getPublicUrl() + '>'
- }
- },
- list: {
- unsubscribe: null
- },
- subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false),
- html,
- text,
-
- attachments,
- encryptionKeys
- };
-
-
- let response;
- try {
- const info = await mailer.sendMassMail(mail);
- response = info.response || info.messageId;
- } catch (err) {
- response = err.response || err.message;
- }
-
- return response;
- }
-
-
- async init(settings) {
+ /*
+ settings is one of:
+ - campaignCid / campaignId
+ or
+ - sendConfiguration, listId, attachments, html, text, subject
+ */
+ async _init(settings) {
this.listsById = new Map(); // listId -> list
this.listsByCid = new Map(); // listCid -> list
this.listsFieldsGrouped = new Map(); // listId -> fieldsGrouped
- this.attachments = [];
await knex.transaction(async tx => {
if (settings.campaignCid) {
this.campaign = await campaigns.rawGetByTx(tx, 'cid', settings.campaignCid);
- } else {
+
+ } else if (settings.campaignId) {
this.campaign = await campaigns.rawGetByTx(tx, 'id', settings.campaignId);
+
+ } else {
+ // We are not within scope of a campaign (i.e. templates in MessageType.TEST message)
+ // This is to fake the campaign for getMessageLinks, which is called inside formatMessage
+ this.campaign = {
+ cid: '[CAMPAIGN_ID]',
+ from_name_override: null,
+ from_email_override: null,
+ reply_to_override: null
+ };
}
- const campaign = this.campaign;
-
- this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.send_configuration, false, true);
-
- for (const listSpec of campaign.lists) {
- const list = await lists.getByIdTx(tx, contextHelpers.getAdminContext(), listSpec.list);
- this.listsById.set(list.id, list);
- this.listsByCid.set(list.cid, list);
- this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
- }
-
- if (campaign.source === CampaignSource.TEMPLATE) {
- this.template = await templates.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.data.sourceTemplate, false);
- }
-
- const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', campaign.id);
- for (const attachment of attachments) {
- this.attachments.push({
- filename: attachment.originalname,
- path: files.getFilePath('campaign', 'attachment', campaign.id, attachment.filename)
- });
+ if (settings.sendConfigurationId) {
+ this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), settings.sendConfigurationId, false, true);
+ } else if (this.campaign.send_configuration) {
+ this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), this.campaign.send_configuration, false, true);
+ } else {
+ enforce(false);
}
this.useVerp = config.verp.enabled && this.sendConfiguration.verp_hostname;
this.useVerpSenderHeader = this.useVerp && !this.sendConfiguration.verp_disable_sender_header;
+
+ if (settings.listId) {
+ const list = await lists.getByIdTx(tx, contextHelpers.getAdminContext(), settings.listId);
+ this.listsById.set(list.id, list);
+ this.listsByCid.set(list.cid, list);
+ this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
+
+ } else if (this.campaign.lists) {
+ for (const listSpec of this.campaign.lists) {
+ const list = await lists.getByIdTx(tx, contextHelpers.getAdminContext(), listSpec.list);
+ this.listsById.set(list.id, list);
+ this.listsByCid.set(list.cid, list);
+ this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
+ }
+
+ } else {
+ enforce(false);
+ }
+
+ if (settings.attachments) {
+ this.attachments = settings.attachments;
+
+ } else if (this.campaign.id) {
+ const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', this.campaign.id);
+
+ this.attachments = [];
+ for (const attachment of attachments) {
+ this.attachments.push({
+ filename: attachment.originalname,
+ path: files.getFilePath('campaign', 'attachment', this.campaign.id, attachment.filename)
+ });
+ }
+
+ } else {
+ this.attachments = [];
+ }
+
+ if (settings.html !== undefined) {
+ this.html = settings.html;
+ this.text = settings.text;
+ } else if (this.campaign.source === CampaignSource.TEMPLATE) {
+ this.template = await templates.getByIdTx(tx, contextHelpers.getAdminContext(), this.campaign.data.sourceTemplate, false);
+ }
+
+ if (settings.subject !== undefined) {
+ this.subject = settings.subject;
+ } else if (this.campaign.subject !== undefined) {
+ this.subject = this.campaign.subject;
+ } else {
+ enforce(false);
+ }
});
}
- async _getMessage(campaign, list, subscriptionGrouped, mergeTags, replaceDataImgs) {
+ async _getMessage(list, subscriptionGrouped, mergeTags, replaceDataImgs) {
let html = '';
let text = '';
let renderTags = false;
+ const campaign = this.campaign;
- if (campaign.source === CampaignSource.URL) {
- const form = tools.getMessageLinks(campaign, list, subscriptionGrouped);
- for (const key in mergeTags) {
- form[key] = mergeTags[key];
- }
-
- const response = await request.post({
- uri: campaign.sourceUrl,
- form,
- resolveWithFullResponse: true
- });
-
- if (response.statusCode !== 200) {
- throw new Error(`Received status code ${httpResponse.statusCode} from ${campaign.sourceUrl}`);
- }
-
- html = response.body;
- text = '';
+ if (this.renderedHtml !== undefined) {
+ html = this.renderedHtml;
+ text = this.renderedText;
renderTags = false;
- } else if (campaign.source === CampaignSource.CUSTOM || campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN || campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
- html = campaign.data.sourceCustom.html;
- text = campaign.data.sourceCustom.text;
+ } else if (this.html !== undefined) {
+ html = this.html;
+ text = this.text;
renderTags = true;
- } else if (campaign.source === CampaignSource.TEMPLATE) {
- const template = this.template;
- html = template.html;
- text = template.text;
- renderTags = true;
+ } else {
+ if (campaign.source === CampaignSource.URL) {
+ const form = tools.getMessageLinks(campaign, list, subscriptionGrouped);
+ for (const key in mergeTags) {
+ form[key] = mergeTags[key];
+ }
+
+ const response = await request.post({
+ uri: campaign.sourceUrl,
+ form,
+ resolveWithFullResponse: true
+ });
+
+ if (response.statusCode !== 200) {
+ throw new Error(`Received status code ${httpResponse.statusCode} from ${campaign.sourceUrl}`);
+ }
+
+ html = response.body;
+ text = '';
+ renderTags = false;
+
+ } else if (campaign.source === CampaignSource.CUSTOM || campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN || campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
+ html = campaign.data.sourceCustom.html;
+ text = campaign.data.sourceCustom.text;
+ renderTags = true;
+
+ } else if (campaign.source === CampaignSource.TEMPLATE) {
+ const template = this.template;
+ html = template.html;
+ text = template.text;
+ renderTags = true;
+ }
+
+ html = await links.updateLinks(campaign, list, subscriptionGrouped, mergeTags, html);
}
- html = await links.updateLinks(campaign, list, subscriptionGrouped, mergeTags, html);
-
const attachments = this.attachments.slice();
if (replaceDataImgs) {
// replace data: images with embedded attachments
@@ -273,6 +221,14 @@ class CampaignSender {
return tags;
}
+ async initByCampaignCid(campaignCid) {
+ await this._init({campaignCid});
+ }
+
+ async initByCampaignId(campaignId) {
+ await this._init({campaignId});
+ }
+
async getMessage(listCid, subscriptionCid) {
const list = this.listsByCid.get(listCid);
const subscriptionGrouped = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, subscriptionCid);
@@ -280,20 +236,36 @@ class CampaignSender {
const campaign = this.campaign;
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
- return await this._getMessage(campaign, list, subscriptionGrouped, mergeTags, false);
+ return await this._getMessage(list, subscriptionGrouped, mergeTags, false);
}
- async sendMessageByEmail(listId, email) {
- const subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), listId, email);
- await this._sendMessage(listId, subscriptionGrouped);
- }
- async sendMessageBySubscriptionId(listId, subscriptionId) {
- const subscriptionGrouped = await subscriptions.getById(contextHelpers.getAdminContext(), listId, subscriptionId);
- await this._sendMessage(listId, subscriptionGrouped);
- }
+ /*
+ subData is one of:
+ - queuedMessage
+ or
+ - email, listId
+ */
+ async _sendMessage(subData) {
+ let msgType;
+ let subscriptionGrouped;
+ let listId;
+
+ if (subData.queuedMessage) {
+ const queuedMessage = subData.queuedMessage;
+ msgType = queuedMessage.type;
+ listId = queuedMessage.list;
+ subscriptionGrouped = await subscriptions.getById(contextHelpers.getAdminContext(), listId, queuedMessage.subscription);
+
+ } else {
+ enforce(subData.email);
+ enforce(subData.listId);
+
+ msgType = MessageType.REGULAR;
+ listId = subData.listId;
+ subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), listId, subData.email);
+ }
- async _sendMessage(listId, subscriptionGrouped) {
const email = subscriptionGrouped.email;
if (await blacklist.isBlacklisted(email)) {
@@ -315,7 +287,7 @@ class CampaignSender {
const sendConfiguration = this.sendConfiguration;
- const {html, text, attachments} = await this._getMessage(campaign, list, subscriptionGrouped, mergeTags, true);
+ const {html, text, attachments} = await this._getMessage(list, subscriptionGrouped, mergeTags, true);
const campaignAddress = [campaign.cid, list.cid, subscriptionGrouped.cid].join('.');
@@ -380,7 +352,7 @@ class CampaignSender {
list: {
unsubscribe: listUnsubscribe
},
- subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false),
+ subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, this.subject, false),
html,
text,
@@ -435,17 +407,47 @@ class CampaignSender {
}
- await knex('campaigns').where('id', campaign.id).increment('delivered');
+ if (msgType === MessageType.REGULAR || msgType === MessageType.TRIGGERED) {
+ await knex('campaigns').where('id', campaign.id).increment('delivered');
+ }
} catch (err) {
+ console.log(err);
+
+ /*
+ { Error: connect ECONNREFUSED 127.0.0.1:55871
+ at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1097:14)
+ cause:
+ { Error: connect ECONNREFUSED 127.0.0.1:55871
+ at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1097:14)
+ stack:
+ 'Error: connect ECONNREFUSED 127.0.0.1:55871\n at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1097:14)',
+ errno: 'ECONNREFUSED',
+ code: 'ECONNECTION',
+ syscall: 'connect',
+ address: '127.0.0.1',
+ port: 55871,
+ command: 'CONN' },
+ isOperational: true,
+ errno: 'ECONNREFUSED',
+ code: 'ECONNECTION',
+ syscall: 'connect',
+ address: '127.0.0.1',
+ port: 55871,
+ command: 'CONN' }
+
+ */
+
status = SubscriptionStatus.BOUNCED;
response = err.response || err.message;
- await knex('campaigns').where('id', campaign.id).increment('delivered').increment('bounced');
+ if (msgType === MessageType.REGULAR || msgType === MessageType.TRIGGERED) {
+ await knex('campaigns').where('id', campaign.id).increment('delivered').increment('bounced');
+ }
}
const now = new Date();
- if (campaign.type === CampaignType.REGULAR || campaign.type === CampaignType.RSS_ENTRY) {
+ if (msgType === MessageType.REGULAR) {
await knex('campaign_messages').insert({
campaign: this.campaign.id,
list: list.id,
@@ -457,16 +459,64 @@ class CampaignSender {
updated: now
});
- } else if (campaign.type = CampaignType.TRIGGERED) {
+ } else if (msgType === MessageType.TRIGGERED || msgType === MessageType.TEST) {
+ if (subData.queuedMessage.data.attachments) {
+ for (const attachment of subData.queuedMessage.data.attachments) {
+ try {
+ // We ignore any errors here because we already sent the message. Thus we have to mark it as completed to avoid sending it again.
+ await knex.transaction(async tx => {
+ await files.unlockTx(tx, 'campaign', 'attachment', attachment.id);
+ });
+ } catch (err) {
+ log.error('CampaignSender', `Error when unlocking attachment ${attachment.id} for ${listId}:${subscriptionGrouped.email} (queuedId: ${subData.queuedMessage.id})`);
+ log.verbose(err.stack);
+ }
+ }
+ }
+
await knex('queued')
- .where({
- campaign: this.campaign.id,
- list: list.id,
- subscription: subscriptionGrouped.id
- })
+ .where({id: subData.queuedMessage.id})
.del();
}
}
+
+ async sendRegularMessage(listId, email) {
+ await this._sendMessage({listId, email});
+ }
}
-module.exports = CampaignSender;
\ No newline at end of file
+CampaignSender.sendQueuedMessage = async (queuedMessage) => {
+ const msgData = queuedMessage.data;
+
+ const cs = new CampaignSender();
+ await cs._init({
+ campaignId: msgData.campaignId,
+ listId: queuedMessage.list,
+ sendConfigurationId: queuedMessage.send_configuration,
+ attachments: msgData.attachments,
+ html: msgData.html,
+ text: msgData.text,
+ subject: msgData.subject
+ });
+
+ await cs._sendMessage({queuedMessage});
+};
+
+CampaignSender.queueMessageTx = async (tx, sendConfigurationId, listId, subscriptionId, messageType, messageData) => {
+ if (messageData.attachments) {
+ for (const attachment of messageData.attachments) {
+ await files.lockTx(tx,'campaign', 'attachment', attachment.id);
+ }
+ }
+
+ await tx('queued').insert({
+ send_configuration: sendConfigurationId,
+ list: listId,
+ subscription: subscriptionId,
+ type: messageType,
+ data: JSON.stringify(messageData)
+ });
+};
+
+module.exports.CampaignSender = CampaignSender;
+module.exports.MessageType = MessageType;
\ No newline at end of file
diff --git a/server/lib/dependency-helpers.js b/server/lib/dependency-helpers.js
index 03ff1e39..02354966 100644
--- a/server/lib/dependency-helpers.js
+++ b/server/lib/dependency-helpers.js
@@ -4,7 +4,6 @@ const knex = require('./knex');
const interoperableErrors = require('../../shared/interoperable-errors');
const entitySettings = require('./entity-settings');
const shares = require('../models/shares');
-const { enforce } = require('./helpers');
const defaultNoOfDependenciesReported = 20;
@@ -21,7 +20,7 @@ async function ensureNoDependencies(tx, context, id, depSpecs) {
if (depSpec.query) {
rows = await depSpec.query(tx).limit(defaultNoOfDependenciesReported + 1);
} else if (depSpec.column) {
- rows = await tx(entityType.entitiesTable).where(depSpec.column, id).select(['id', 'name']).limit(defaultNoOfDependenciesReported + 1);
+ rows = await tx(entityType.entitiesTable).where(depSpec.column, id).forShare().select(['id', 'name']).limit(defaultNoOfDependenciesReported + 1);
} else if (depSpec.rows) {
rows = await depSpec.rows(tx, defaultNoOfDependenciesReported + 1)
}
diff --git a/server/lib/entity-settings.js b/server/lib/entity-settings.js
index cf23e724..3bb34927 100644
--- a/server/lib/entity-settings.js
+++ b/server/lib/entity-settings.js
@@ -44,6 +44,7 @@ const entityTypes = {
},
attachment: {
table: 'files_campaign_attachment',
+ inUseTable: 'files_campaign_attachment_usage',
permissions: {
view: 'viewAttachments',
manage: 'manageAttachments'
diff --git a/server/lib/helpers.js b/server/lib/helpers.js
index 2f87df96..de5a7baa 100644
--- a/server/lib/helpers.js
+++ b/server/lib/helpers.js
@@ -28,11 +28,11 @@ function filterObject(obj, allowedKeys) {
return result;
}
-function castToInteger(id) {
+function castToInteger(id, msg) {
const val = parseInt(id);
if (!Number.isInteger(val)) {
- throw new Error('Invalid id');
+ throw new Error(msg || 'Invalid id');
}
return val;
diff --git a/server/lib/mailers.js b/server/lib/mailers.js
index 7880ce1a..e9faf41a 100644
--- a/server/lib/mailers.js
+++ b/server/lib/mailers.js
@@ -94,14 +94,18 @@ async function _sendMail(transport, mail, template) {
return await trySendAsync();
}
-async function _sendTransactionalMail(transport, mail, template) {
- const sendConfiguration = transport.mailer.sendConfiguration;
-
+async function _sendTransactionalMail(transport, mail) {
if (!mail.headers) {
mail.headers = {};
}
mail.headers['X-Sending-Zone'] = 'transactional';
+ return await _sendMail(transport, mail);
+}
+
+async function _sendTransactionalMailBasedOnTemplate(transport, mail, template) {
+ const sendConfiguration = transport.mailer.sendConfiguration;
+
mail.from = {
name: sendConfiguration.from_name,
address: sendConfiguration.from_email
@@ -129,7 +133,7 @@ async function _sendTransactionalMail(transport, mail, template) {
});
}
- return await _sendMail(transport, mail);
+ return await _sendTransactionalMail(transport, mail);
}
async function _createTransport(sendConfiguration) {
@@ -263,7 +267,8 @@ async function _createTransport(sendConfiguration) {
transport.mailer = {
sendConfiguration,
throttleWait: bluebird.promisify(throttleWait),
- sendTransactionalMail: async (mail, template) => await _sendTransactionalMail(transport, mail, template),
+ sendTransactionalMail: async (mail, template) => await _sendTransactionalMail(transport, mail),
+ sendTransactionalMailBasedOnTemplate: async (mail, template) => await _sendTransactionalMailBasedOnTemplate(transport, mail, template),
sendMassMail: async (mail, template) => await _sendMail(transport, mail)
};
diff --git a/server/lib/subscription-mail-helpers.js b/server/lib/subscription-mail-helpers.js
index ad7d0202..838cb75c 100644
--- a/server/lib/subscription-mail-helpers.js
+++ b/server/lib/subscription-mail-helpers.js
@@ -139,7 +139,7 @@ async function _sendMail(list, email, template, locale, subjectKey, relativeUrls
try {
if (list.send_configuration) {
const mailer = await mailers.getOrCreateMailer(list.send_configuration);
- await mailer.sendTransactionalMail({
+ await mailer.sendTransactionalMailBasedOnTemplate({
to: {
name: getDisplayName(flds, subscription),
address: email
diff --git a/server/lib/template-sender.js b/server/lib/template-sender.js
deleted file mode 100644
index 534df7fe..00000000
--- a/server/lib/template-sender.js
+++ /dev/null
@@ -1,87 +0,0 @@
-'use strict';
-
-const mailers = require('./mailers');
-const tools = require('./tools');
-const templates = require('../models/templates');
-const { getMergeTagsForBases } = require('../../shared/templates');
-const { getTrustedUrl, getSandboxUrl, getPublicUrl } = require('../lib/urls');
-
-class TemplateSender {
- constructor(options) {
- this.defaultOptions = {
- maxMails: 100,
- ...options
- };
- }
-
- async send(params) {
- const options = { ...this.defaultOptions, ...params };
- this._validateMailOptions(options);
-
- const [mailer, template] = await Promise.all([
- mailers.getOrCreateMailer(
- options.sendConfigurationId
- ),
- templates.getById(
- options.context,
- options.templateId,
- false
- )
- ]);
-
- const variables = {
- EMAIL: options.email,
- ...getMergeTagsForBases(getTrustedUrl(), getSandboxUrl(), getPublicUrl()),
- ...options.variables
- };
-
- const html = tools.formatTemplate(
- template.html,
- null,
- variables,
- true
- );
- const subject = tools.formatTemplate(
- options.subject || template.description || template.name,
- variables
- );
- return mailer.sendTransactionalMail(
- {
- to: options.email,
- subject
- },
- {
- html: { template: html },
- data: options.data,
- locale: options.locale
- }
- );
- }
-
- _validateMailOptions(options) {
- let { context, email, locale, templateId } = options;
-
- if (!templateId) {
- throw new Error('Missing templateId');
- }
- if (!context) {
- throw new Error('Missing context');
- }
- if (!email || email.length === 0) {
- throw new Error('Missing email');
- }
- if (typeof email === 'string') {
- email = email.split(',');
- }
- if (email.length > options.maxMails) {
- throw new Error(
- `Cannot send more than ${options.maxMails} emails at once`
- );
- }
- if (!locale) {
- throw new Error('Missing locale');
- }
- }
-}
-
-module.exports = TemplateSender;
diff --git a/server/models/blacklist.js b/server/models/blacklist.js
index 95f92460..8dca9c17 100644
--- a/server/models/blacklist.js
+++ b/server/models/blacklist.js
@@ -41,16 +41,17 @@ async function search(context, offset, limit, search) {
async function add(context, email) {
enforce(email, 'Email has to be set');
- return await knex.transaction(async tx => {
- shares.enforceGlobalPermission(context, 'manageBlacklist');
-
- const existing = await tx('blacklist').where('email', email).first();
- if (!existing) {
- await tx('blacklist').insert({email});
- }
+ shares.enforceGlobalPermission(context, 'manageBlacklist');
+ try {
+ await knex('blacklist').insert({email});
await activityLog.logBlacklistActivity(BlacklistActivityType.ADD, email);
- });
+ } catch (err) {
+ if (err.code === 'ER_DUP_ENTRY') {
+ } else {
+ throw err;
+ }
+ }
}
async function remove(context, email) {
diff --git a/server/models/campaigns.js b/server/models/campaigns.js
index 483ee32f..98a02592 100644
--- a/server/models/campaigns.js
+++ b/server/models/campaigns.js
@@ -21,12 +21,14 @@ const {LinkId} = require('./links');
const feedcheck = require('../lib/feedcheck');
const contextHelpers = require('../lib/context-helpers');
const {convertFileURLs} = require('../lib/campaign-content');
+const {CampaignSender, MessageType} = require('../lib/campaign-sender');
+const lists = require('./lists');
const {EntityActivityType, CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const allowedKeysCommon = ['name', 'description', 'segment', 'namespace',
- 'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
+ 'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
const allowedKeysCreate = new Set(['type', 'source', ...allowedKeysCommon]);
const allowedKeysCreateRssEntry = new Set(['type', 'source', 'parent', ...allowedKeysCommon]);
@@ -168,7 +170,7 @@ async function listTestUsersDTAjax(context, campaignId, params) {
let subsQry;
if (subsQrys.length === 1) {
- const subsUnionSql = '(' + subsQrys[0].sql + ') as `test_subscriptions`'
+ const subsUnionSql = '(' + subsQrys[0].sql + ') as `test_subscriptions`';
subsQry = knex.raw(subsUnionSql, subsQrys[0].bindings);
} else {
@@ -342,7 +344,7 @@ async function rawGetByTx(tx, key, id) {
.groupBy('campaigns.id')
.select([
'campaigns.id', 'campaigns.cid', 'campaigns.name', 'campaigns.description', 'campaigns.namespace', 'campaigns.status', 'campaigns.type', 'campaigns.source',
- 'campaigns.send_configuration', 'campaigns.from_name_override', 'campaigns.from_email_override', 'campaigns.reply_to_override', 'campaigns.subject_override',
+ 'campaigns.send_configuration', 'campaigns.from_name_override', 'campaigns.from_email_override', 'campaigns.reply_to_override', 'campaigns.subject',
'campaigns.data', 'campaigns.click_tracking_disabled', 'campaigns.open_tracking_disabled', 'campaigns.unsubscribe_url', 'campaigns.scheduled',
'campaigns.delivered', 'campaigns.unsubscribed', 'campaigns.bounced', 'campaigns.complained', 'campaigns.blacklisted', 'campaigns.opened', 'campaigns.clicks',
knex.raw(`GROUP_CONCAT(CONCAT_WS(\':\', campaign_lists.list, campaign_lists.segment) ORDER BY campaign_lists.id SEPARATOR \';\') as lists`)
@@ -632,21 +634,32 @@ async function remove(context, id) {
});
}
-async function enforceSendPermissionTx(tx, context, campaignId) {
+async function enforceSendPermissionTx(tx, context, campaignOrCampaignId, isToTestUsers, listId) {
let campaign;
- if (typeof campaignId === 'object') {
- campaign = campaignId;
+ if (typeof campaignOrCampaignId === 'object') {
+ campaign = campaignOrCampaignId;
} else {
- campaign = await getByIdTx(tx, context, campaignId, false);
+ campaign = await getByIdTx(tx, context, campaignOrCampaignId, false);
}
const sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.send_configuration, false, false);
- const requiredPermission = getSendConfigurationPermissionRequiredForSend(campaign, sendConfiguration);
+ const requiredSendConfigurationPermission = getSendConfigurationPermissionRequiredForSend(campaign, sendConfiguration);
+ await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', campaign.send_configuration, requiredSendConfigurationPermission);
- await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', campaign.send_configuration, requiredPermission);
- await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaign.id, 'send');
+ const requiredListAndCampaignPermission = isToTestUsers ? 'sendToTestUsers' : 'send';
+
+ await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaign.id, requiredListAndCampaignPermission);
+
+ if (listId) {
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listId, requiredListAndCampaignPermission);
+
+ } else {
+ for (const listIds of campaign.lists) {
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listIds.list, requiredListAndCampaignPermission);
+ }
+ }
}
@@ -845,12 +858,9 @@ async function getSubscribersQueryGeneratorTx(tx, campaignId) {
async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, scheduled = null) {
await knex.transaction(async tx => {
- const entity = await tx('campaigns').where('id', campaignId).first();
- if (!entity) {
- throw new interoperableErrors.NotFoundError();
- }
+ const entity = await getByIdTx(tx, context, campaignId, false);
- await enforceSendPermissionTx(tx, context, entity);
+ await enforceSendPermissionTx(tx, context, entity, false);
if (!permittedCurrentStates.includes(entity.status)) {
throw new interoperableErrors.InvalidStateError(invalidStateMessage);
@@ -869,11 +879,11 @@ async function _changeStatus(context, campaignId, permittedCurrentStates, newSta
async function start(context, campaignId, startAt) {
- await _changeStatus(context, campaignId, [CampaignStatus.IDLE, CampaignStatus.SCHEDULED, CampaignStatus.PAUSED, CampaignStatus.FINISHED], CampaignStatus.SCHEDULED, 'Cannot start campaign until it is in IDLE or PAUSED state', startAt);
+ await _changeStatus(context, campaignId, [CampaignStatus.IDLE, CampaignStatus.PAUSED, CampaignStatus.FINISHED], CampaignStatus.SCHEDULED, 'Cannot start campaign until it is in IDLE, PAUSED, or FINISHED state', startAt);
}
async function stop(context, campaignId) {
- await _changeStatus(context, campaignId, [CampaignStatus.SCHEDULED], CampaignStatus.PAUSED, 'Cannot stop campaign until it is in SCHEDULED state');
+ await _changeStatus(context, campaignId, [CampaignStatus.SCHEDULED, CampaignStatus.SENDING], CampaignStatus.PAUSING, 'Cannot stop campaign until it is in SCHEDULED or SENDING state');
}
async function reset(context, campaignId) {
@@ -944,6 +954,103 @@ async function fetchRssCampaign(context, cid) {
});
}
+async function testSend(context, data) {
+ // Though it's a bit counterintuitive, this handles also test sends of a template (i.e. without any campaign id)
+
+ await knex.transaction(async tx => {
+ const processSubscriber = async (sendConfigurationId, listId, subscriptionId, messageData) => {
+ await CampaignSender.queueMessageTx(tx, sendConfigurationId, listId, subscriptionId, MessageType.TEST, messageData);
+
+ await activityLog.logEntityActivity('campaign', CampaignActivityType.TEST_SEND, campaignId, {list: listId, subscription: subscriptionId});
+ };
+
+ const campaignId = data.campaignId;
+
+ if (campaignId) { // This means we are sending a campaign
+ /*
+ Data coming from the client:
+ - html, text
+ - subjectPrepend, subjectAppend
+ - listCid, subscriptionCid
+ - listId, segmentId
+ */
+
+ const campaign = await getByIdTx(tx, context, campaignId, false);
+ const sendConfigurationId = campaign.send_configuration;
+
+ const messageData = {
+ campaignId: campaignId,
+ subject: data.subjectPrepend + campaign.subject + data.subjectAppend,
+ html: data.html, // The html and text may be undefined
+ text: data.text,
+ attachments: []
+ };
+
+ const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', campaignId);
+ for (const attachment of attachments) {
+ messageData.attachments.push({
+ filename: attachment.originalname,
+ path: files.getFilePath('campaign', 'attachment', campaign.id, attachment.filename),
+ id: attachment.id
+ });
+ }
+
+
+ let listId = data.listId;
+ if (!listId && data.listCid) {
+ const list = await lists.getByCidTx(tx, context, data.listCid);
+ listId = list.id;
+ }
+
+ const segmentId = data.segmentId;
+
+ if (listId) {
+ await enforceSendPermissionTx(tx, context, campaign, true, listId);
+
+ if (data.subscriptionCid) {
+ const subscriber = await subscriptions.getByCidTx(tx, context, listId, data.subscriptionCid);
+ await processSubscriber(sendConfigurationId, listId, subscriber.id, messageData);
+
+ } else {
+ const subscribers = await subscriptions.listTestUsersTx(tx, context, listId, segmentId);
+ for (const subscriber of subscribers) {
+ await processSubscriber(sendConfigurationId, listId, subscriber.id, messageData);
+ }
+ }
+
+ } else {
+ for (const lstSeg of campaign.lists) {
+ await enforceSendPermissionTx(tx, context, campaign, true, lstSeg.list);
+
+ const subscribers = await subscriptions.listTestUsersTx(tx, context, lstSeg.list, segmentId);
+ for (const subscriber of subscribers) {
+ await processSubscriber(sendConfigurationId, lstSeg.list, subscriber.id, messageData);
+ }
+ }
+ }
+
+ } else { // This means we are sending a template
+ /*
+ Data coming from the client:
+ - html, text
+ - listCid, subscriptionCid, sendConfigurationId
+ */
+
+ const messageData = {
+ subject: 'Test',
+ html: data.html,
+ text: data.text
+ };
+
+ const list = await lists.getByCidTx(tx, context, data.listCid);
+ const subscriber = await subscriptions.getByCidTx(tx, context, list.id, data.subscriptionCid);
+ await processSubscriber(data.sendConfigurationId, list.id, subscriber.id, messageData);
+ }
+ });
+
+ senders.scheduleCheck();
+}
+
module.exports.Content = Content;
module.exports.hash = hash;
@@ -986,4 +1093,6 @@ module.exports.rawGetByTx = rawGetByTx;
module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
module.exports.getStatisticsOpened = getStatisticsOpened;
-module.exports.fetchRssCampaign = fetchRssCampaign;
\ No newline at end of file
+module.exports.fetchRssCampaign = fetchRssCampaign;
+
+module.exports.testSend = testSend;
\ No newline at end of file
diff --git a/server/models/files.js b/server/models/files.js
index 2ceaf928..01afe512 100644
--- a/server/models/files.js
+++ b/server/models/files.js
@@ -45,7 +45,7 @@ async function listDTAjax(context, type, subType, entityId, params) {
await shares.enforceEntityPermission(context, type, entityId, getFilesPermission(type, subType, 'view'));
return await dtHelpers.ajaxList(
params,
- builder => builder.from(getFilesTable(type, subType)).where({entity: entityId}),
+ builder => builder.from(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false}),
['id', 'originalname', 'filename', 'size', 'created']
);
}
@@ -53,7 +53,7 @@ async function listDTAjax(context, type, subType, entityId, params) {
async function listTx(tx, context, type, subType, entityId) {
enforceTypePermitted(type, subType);
await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view'));
- return await tx(getFilesTable(type, subType)).where({entity: entityId}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc');
+ return await tx(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc');
}
async function list(context, type, subType, entityId) {
@@ -65,7 +65,7 @@ async function list(context, type, subType, entityId) {
async function getFileById(context, type, subType, id) {
enforceTypePermitted(type, subType);
const file = await knex.transaction(async tx => {
- const file = await tx(getFilesTable(type, subType)).where('id', id).first();
+ const file = await tx(getFilesTable(type, subType)).where({id: id, delete_pending: false}).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'view'));
return file;
});
@@ -85,7 +85,7 @@ async function _getFileBy(context, type, subType, entityId, key, value) {
enforceTypePermitted(type, subType);
const file = await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view'));
- const file = await tx(getFilesTable(type, subType)).where({entity: entityId, [key]: value}).first();
+ const file = await tx(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false, [key]: value}).first();
return file;
});
@@ -155,7 +155,7 @@ async function createFiles(context, type, subType, entityId, files, replacementB
await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'manage'));
- const existingNamesRows = await tx(getFilesTable(type, subType)).where('entity', entityId).select(['id', 'filename', 'originalname']);
+ const existingNamesRows = await tx(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false}).select(['id', 'filename', 'originalname']);
const existingNameSet = new Set();
for (const row of existingNamesRows) {
@@ -275,18 +275,49 @@ async function createFiles(context, type, subType, entityId, files, replacementB
}
}
+async function lockTx(tx, type, subType, id) {
+ enforceTypePermitted(type, subType);
+ const filesTableName = getFilesTable(type, subType);
+ await tx(filesTableName).where('id', id).increment('lock_count');
+}
+
+async function unlockTx(tx, type, subType, id) {
+ enforceTypePermitted(type, subType);
+
+ const filesTableName = getFilesTable(type, subType);
+ const file = await tx(filesTableName).where('id', id).first();
+
+ enforce(file, `File ${id} not found`);
+ enforce(file.lock_count > 0, `Corrupted lock count at file ${id}`);
+
+ if (file.lock_count === 1 && file.delete_pending) {
+ await tx(filesTableName).where('id', id).del();
+
+ const filePath = getFilePath(type, subType, file.entity, file.filename);
+ await fs.removeAsync(filePath);
+
+ } else {
+ await tx(filesTableName).where('id', id).update({lock_count: file.lock_count - 1});
+ }
+}
+
async function removeFile(context, type, subType, id) {
enforceTypePermitted(type, subType);
- const file = await knex.transaction(async tx => {
- const file = await tx(getFilesTable(type, subType)).where('id', id).select('entity', 'filename').first();
+ await knex.transaction(async tx => {
+ const filesTableName = getFilesTable(type, subType);
+ const file = await tx(filesTableName).where('id', id).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'manage'));
- await tx(getFilesTable(type, subType)).where('id', id).del();
- return {filename: file.filename, entity: file.entity};
- });
- const filePath = getFilePath(type, subType, file.entity, file.filename);
- await fs.removeAsync(filePath);
+ if (!file.lock_count) {
+ await tx(filesTableName).where('id', file.id).del();
+
+ const filePath = getFilePath(type, subType, file.entity, file.filename);
+ await fs.removeAsync(filePath);
+ } else {
+ await tx(filesTableName).where('id', file.id).update({delete_pending: true});
+ }
+ });
}
async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toType, toSubType, toEntityId) {
@@ -296,7 +327,7 @@ async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toTyp
enforceTypePermitted(toType, toSubType);
await shares.enforceEntityPermissionTx(tx, context, toType, toEntityId, getFilesPermission(toType, toSubType, 'manage'));
- const rows = await tx(getFilesTable(fromType, fromSubType)).where({entity: fromEntityId});
+ const rows = await tx(getFilesTable(fromType, fromSubType)).where({entity: fromEntityId, delete_pending: false});
for (const row of rows) {
const fromFilePath = getFilePath(fromType, fromSubType, fromEntityId, row.filename);
const toFilePath = getFilePath(toType, toSubType, toEntityId, row.filename);
@@ -339,4 +370,6 @@ module.exports.getFileUrl = getFileUrl;
module.exports.getFilePath = getFilePath;
module.exports.copyAllTx = copyAllTx;
module.exports.removeAllTx = removeAllTx;
+module.exports.lockTx = lockTx;
+module.exports.unlockTx = unlockTx;
module.exports.ReplacementBehavior = ReplacementBehavior;
diff --git a/server/models/links.js b/server/models/links.js
index 9c6acc0d..3eb585f2 100644
--- a/server/models/links.js
+++ b/server/models/links.js
@@ -11,6 +11,7 @@ const he = require('he');
const { getPublicUrl } = require('../lib/urls');
const tools = require('../lib/tools');
const shortid = require('shortid');
+const {enforce} = require('../lib/helpers');
const LinkId = {
OPEN: -1,
@@ -103,16 +104,16 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
}
async function addOrGet(campaignId, url) {
- return await knex.transaction(async tx => {
- const link = await tx('links').select(['id', 'cid']).where({
- campaign: campaignId,
- url
- }).first();
+ const link = await knex('links').select(['id', 'cid']).where({
+ campaign: campaignId,
+ url
+ }).first();
- if (!link) {
- let cid = shortid.generate();
+ if (!link) {
+ let cid = shortid.generate();
- const ids = await tx('links').insert({
+ try {
+ const ids = await knex('links').insert({
campaign: campaignId,
cid,
url
@@ -122,10 +123,21 @@ async function addOrGet(campaignId, url) {
id: ids[0],
cid
};
- } else {
- return link;
+ } catch (err) {
+ if (err.code === 'ER_DUP_ENTRY') {
+ const link = await knex('links').select(['id', 'cid']).where({
+ campaign: campaignId,
+ url
+ }).first();
+
+ enforce(link);
+ return link;
+ }
}
- });
+
+ } else {
+ return link;
+ }
}
async function updateLinks(campaign, list, subscription, mergeTags, message) {
diff --git a/server/models/send-configurations.js b/server/models/send-configurations.js
index 7e0ca860..7fa4c909 100644
--- a/server/models/send-configurations.js
+++ b/server/models/send-configurations.js
@@ -14,7 +14,7 @@ const mailers = require('../lib/mailers');
const senders = require('../lib/senders');
const dependencyHelpers = require('../lib/dependency-helpers');
-const allowedKeys = new Set(['name', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'subject', 'subject_overridable', 'x_mailer', 'verp_hostname', 'verp_disable_sender_header', 'mailer_type', 'mailer_settings', 'namespace']);
+const allowedKeys = new Set(['name', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'x_mailer', 'verp_hostname', 'verp_disable_sender_header', 'mailer_type', 'mailer_settings', 'namespace']);
const allowedMailerTypes = new Set(Object.values(MailerType));
@@ -75,7 +75,7 @@ async function _getByTx(tx, context, key, id, withPermissions, withPrivateData)
entity.mailer_settings = JSON.parse(entity.mailer_settings);
} else {
entity = await tx('send_configurations').where(key, id).select(
- ['id', 'name', 'cid', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'subject', 'subject_overridable']
+ ['id', 'name', 'cid', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable']
).first();
if (!entity) {
diff --git a/server/models/subscriptions.js b/server/models/subscriptions.js
index f9276073..3c02b0c5 100644
--- a/server/models/subscriptions.js
+++ b/server/models/subscriptions.js
@@ -19,6 +19,8 @@ const lists = require('./lists');
const allowedKeysBase = new Set(['email', 'tz', 'is_test', 'status']);
+const TEST_USERS_LIST_LIMIT = 1000;
+
const fieldTypes = {};
const Cardinality = {
@@ -409,6 +411,32 @@ async function list(context, listId, grouped, offset, limit) {
});
}
+async function listTestUsersTx(tx, context, listId, segmentId, grouped) {
+ await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
+
+ let entitiesQry = tx(getSubscriptionTableName(listId)).orderBy('id', 'asc').where('is_test', true).limit(TEST_USERS_LIST_LIMIT);
+
+ if (segmentId) {
+ const addSegmentQuery = await segments.getQueryGeneratorTx(tx, listId, segmentId);
+
+ entitiesQry = entitiesQry.where(function() {
+ addSegmentQuery(this);
+ });
+ }
+
+ const entities = await entitiesQry;
+
+ if (grouped) {
+ const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
+
+ for (const entity of entities) {
+ groupSubscription(groupedFieldsMap, entity);
+ }
+ }
+
+ return entities;
+}
+
// Note that this does not do all the work in the transaction. Thus it is prone to fail if the list is deleted in during the run of the function
async function* listIterator(context, listId, segmentId, grouped = true) {
let groupedFieldsMap;
@@ -855,6 +883,7 @@ module.exports.getByEmail = getByEmail;
module.exports.list = list;
module.exports.listIterator = listIterator;
module.exports.listDTAjax = listDTAjax;
+module.exports.listTestUsersTx = listTestUsersTx;
module.exports.listTestUsersDTAjax = listTestUsersDTAjax;
module.exports.serverValidate = serverValidate;
module.exports.create = create;
diff --git a/server/models/templates.js b/server/models/templates.js
index b18b7186..c04b2cca 100644
--- a/server/models/templates.js
+++ b/server/models/templates.js
@@ -7,11 +7,17 @@ const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../../shared/interoperable-errors');
const namespaceHelpers = require('../lib/namespace-helpers');
const shares = require('./shares');
-const reports = require('./reports');
const files = require('./files');
const dependencyHelpers = require('../lib/dependency-helpers');
const {convertFileURLs} = require('../lib/campaign-content');
+const mailers = require('../lib/mailers');
+const tools = require('../lib/tools');
+const sendConfigurations = require('./send-configurations');
+const { getMergeTagsForBases } = require('../../shared/templates');
+const { getTrustedUrl, getSandboxUrl, getPublicUrl } = require('../lib/urls');
+const htmlToText = require('html-to-text');
+
const allowedKeys = new Set(['name', 'description', 'type', 'data', 'html', 'text', 'namespace']);
function hash(entity) {
@@ -145,6 +151,65 @@ async function remove(context, id) {
});
}
+const MAX_EMAIL_COUNT = 100;
+async function sendAsTransactionalEmail(context, templateId, sendConfigurationId, emails, subject, mergeTags) {
+ // TODO - Update this to use CampaignSender.queueMessageTx (with renderedHtml and renderedText)
+
+ if (emails.length > MAX_EMAIL_COUNT) {
+ throw new Error(`Cannot send more than ${MAX_EMAIL_COUNT} emails at once`);
+ }
+
+ await knex.transaction(async tx => {
+ const template = await getByIdTx(tx, context, templateId,false);
+ const sendConfiguration = await sendConfigurations.getByIdTx(tx, context, sendConfigurationId, false, false);
+
+ await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', sendConfigurationId, 'sendWithoutOverrides');
+
+ const mailer = await mailers.getOrCreateMailer(sendConfigurationId);
+
+ const variablesSkeleton = {
+ ...getMergeTagsForBases(getTrustedUrl(), getSandboxUrl(), getPublicUrl()),
+ ...mergeTags
+ };
+
+ for (const email of emails) {
+ const variables = {
+ ...variablesSkeleton,
+ EMAIL: email
+ };
+
+ const html = tools.formatTemplate(
+ template.html,
+ null,
+ variables,
+ true
+ );
+
+ const text = (template.text || '').trim()
+ ? tools.formatTemplate(
+ template.text,
+ null,
+ variables,
+ false
+ ) : htmlToText.fromString(html, {wordwrap: 130});
+
+ return mailer.sendTransactionalMail(
+ {
+ to: email,
+ subject,
+ from: {
+ name: sendConfiguration.from_name,
+ address: sendConfiguration.from_email
+ },
+ html,
+ text
+ }
+ );
+ }
+ });
+}
+
+
module.exports.hash = hash;
module.exports.getByIdTx = getByIdTx;
module.exports.getById = getById;
@@ -153,3 +218,4 @@ module.exports.listByNamespaceDTAjax = listByNamespaceDTAjax;
module.exports.create = create;
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
module.exports.remove = remove;
+module.exports.sendAsTransactionalEmail = sendAsTransactionalEmail;
diff --git a/server/models/triggers.js b/server/models/triggers.js
index 5a02de7b..b8434e24 100644
--- a/server/models/triggers.js
+++ b/server/models/triggers.js
@@ -65,7 +65,7 @@ async function _validateAndPreprocess(tx, context, campaignId, entity) {
await shares.enforceEntityPermissionTx(tx, context, 'campaign', entity.source_campaign, 'view');
}
- await campaigns.enforceSendPermissionTx(tx, context, campaignId);
+ await campaigns.enforceSendPermissionTx(tx, context, campaignId, false);
}
async function create(context, campaignId, entity) {
diff --git a/server/models/users.js b/server/models/users.js
index 5b7da1bd..038fc045 100644
--- a/server/models/users.js
+++ b/server/models/users.js
@@ -311,7 +311,7 @@ async function sendPasswordReset(locale, usernameOrEmail) {
const { adminEmail } = await settings.get(contextHelpers.getAdminContext(), ['adminEmail']);
const mailer = await mailers.getOrCreateMailer();
- await mailer.sendTransactionalMail({
+ await mailer.sendTransactionalMailBasedOnTemplate({
to: {
address: user.email
},
diff --git a/server/routes/api.js b/server/routes/api.js
index ebb822e4..0a8f4df4 100644
--- a/server/routes/api.js
+++ b/server/routes/api.js
@@ -16,8 +16,10 @@ const contextHelpers = require('../lib/context-helpers');
const shares = require('../models/shares');
const slugify = require('slugify');
const passport = require('../lib/passport');
-const TemplateSender = require('../lib/template-sender');
+const templates = require('../models/templates');
const campaigns = require('../models/campaigns');
+const {castToInteger} = require('../lib/helpers');
+const {getSystemSendConfigurationId} = require('../../shared/send-configurations');
class APIError extends Error {
constructor(msg, status) {
@@ -236,7 +238,7 @@ router.postAsync('/blacklist/add', passport.loggedIn, async (req, res) => {
input[(key || '').toString().trim().toUpperCase()] = (req.body[key] || '').toString().trim();
});
if (!(input.EMAIL) || (input.EMAIL === '')) {
- throw new Error('EMAIL argument is required');
+ throw new APIError('EMAIL argument is required', 400);
}
await blacklist.add(req.context, input.EMAIL);
@@ -253,7 +255,7 @@ router.postAsync('/blacklist/delete', passport.loggedIn, async (req, res) => {
input[(key || '').toString().trim().toUpperCase()] = (req.body[key] || '').toString().trim();
});
if (!(input.EMAIL) || (input.EMAIL === '')) {
- throw new Error('EMAIL argument is required');
+ throw new APIError('EMAIL argument is required', 400);
}
await blacklist.remove(req.oontext, input.EMAIL);
@@ -288,32 +290,30 @@ router.getAsync('/rss/fetch/:campaignCid', passport.loggedIn, async (req, res) =
router.postAsync('/templates/:templateId/send', async (req, res) => {
const input = {};
- Object.keys(req.body).forEach(key => {
- input[
- (key || '')
- .toString()
- .trim()
- .toUpperCase()
- ] = req.body[key] || '';
- });
- try {
- const templateSender = new TemplateSender({
- context: req.context,
- locale: req.locale,
- templateId: req.params.templateId
- });
- const info = await templateSender.send({
- data: input.DATA,
- email: input.EMAIL,
- sendConfigurationId: input.SEND_CONFIGURATION_ID,
- subject: input.SUBJECT,
- variables: input.VARIABLES
- });
- res.status(200).json({ data: info });
- } catch (e) {
- throw new APIError(e.message, 400);
+ for (const key in req.body) {
+ const sanitizedKey = key.toString().trim().toUpperCase();
+ input[sanitizedKey] = req.body[key] || '';
}
+
+ const templateId = castToInteger(req.params.templateId, 'Invalid template ID');
+
+ let sendConfigurationId;
+ if (!('SEND_CONFIGURATION_ID' in input)) {
+ sendConfigurationId = getSystemSendConfigurationId();
+ } else {
+ sendConfigurationId = castToInteger(input.SEND_CONFIGURATION_ID, 'Invalid send configuration ID');
+ }
+
+ if (!input.EMAIL || input.EMAIL === 0) {
+ throw new APIError('Missing email(s)', 400);
+ }
+
+ const emails = input.EMAIL.split(',');
+
+ const info = await templates.sendAsTransactionalEmail(req.context, templateId, sendConfigurationId, emails, input.SUBJECT, input.VARIABLES);
+
+ res.json({ data: info });
});
module.exports = router;
diff --git a/server/routes/archive.js b/server/routes/archive.js
index 5e77de78..566fa146 100644
--- a/server/routes/archive.js
+++ b/server/routes/archive.js
@@ -1,12 +1,12 @@
'use strict';
const router = require('../lib/router-async').create();
-const CampaignSender = require('../lib/campaign-sender');
+const {CampaignSender} = require('../lib/campaign-sender');
router.get('/:campaign/:list/:subscription', (req, res, next) => {
const cs = new CampaignSender();
- cs.init({campaignCid: req.params.campaign})
+ cs.initByCampaignCid(req.params.campaign)
.then(() => cs.getMessage(req.params.list, req.params.subscription))
.then(result => {
const {html} = result;
diff --git a/server/routes/rest/campaigns.js b/server/routes/rest/campaigns.js
index 77f5a083..f9009e9a 100644
--- a/server/routes/rest/campaigns.js
+++ b/server/routes/rest/campaigns.js
@@ -114,6 +114,12 @@ router.postAsync('/campaigns-link-clicks-table/:campaignId', passport.loggedIn,
return res.json(await campaigns.listLinkClicksDTAjax(req.context, castToInteger(req.params.campaignId), req.body));
});
+router.postAsync('/campaign-test-send', passport.loggedIn, passport.csrfProtection, async (req, res) => {
+ const data = req.body;
+ const result = await campaigns.testSend(req.context, data);
+ return res.json(result);
+});
+
module.exports = router;
\ No newline at end of file
diff --git a/server/routes/rest/templates.js b/server/routes/rest/templates.js
index 67f666a6..ee7ec252 100644
--- a/server/routes/rest/templates.js
+++ b/server/routes/rest/templates.js
@@ -5,7 +5,6 @@ const templates = require('../../models/templates');
const router = require('../../lib/router-async').create();
const {castToInteger} = require('../../lib/helpers');
-const CampaignSender = require('../../lib/campaign-sender');
router.getAsync('/templates/:templateId', passport.loggedIn, async (req, res) => {
@@ -39,10 +38,4 @@ router.postAsync('/templates-by-namespace-table/:namespaceId', passport.loggedIn
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) => {
- const data = req.body;
- const result = await CampaignSender.testSend(req.context, data.listCid, data.subscriptionCid, data.campaignId, data.sendConfigurationId, data.html, data.text);
- return res.json(result);
-});
-
module.exports = router;
\ No newline at end of file
diff --git a/server/services/feedcheck.js b/server/services/feedcheck.js
index 170b65d7..ed23ee5e 100644
--- a/server/services/feedcheck.js
+++ b/server/services/feedcheck.js
@@ -112,7 +112,7 @@ async function run() {
from_name_override: rssCampaign.from_name_override,
from_email_override: rssCampaign.from_email_override,
reply_to_override: rssCampaign.reply_to_override,
- subject_override: rssCampaign.subject_override,
+ subject: rssCampaign.subject,
data: campaignData,
click_tracking_disabled: rssCampaign.click_tracking_disabled,
diff --git a/server/services/sender-master.js b/server/services/sender-master.js
index 1bec702d..05f4aaf5 100644
--- a/server/services/sender-master.js
+++ b/server/services/sender-master.js
@@ -6,35 +6,63 @@ const log = require('../lib/log');
const path = require('path');
const knex = require('../lib/knex');
const {CampaignStatus, CampaignType} = require('../../shared/campaigns');
-const { enforce } = require('../lib/helpers');
const campaigns = require('../models/campaigns');
const builtinZoneMta = require('../lib/builtin-zone-mta');
const {CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
+const {MessageType} = require('../lib/campaign-sender')
require('../lib/fork');
+class Notifications {
+ constructor() {
+ this.conts = new Map();
+ }
+
+ notify(id) {
+ const cont = this.conts.get(id);
+ if (cont) {
+ for (const cb of cont) {
+ setImmediate(cb);
+ }
+ this.conts.delete(id);
+ }
+ }
+
+ async waitFor(id) {
+ let cont = this.conts.get(id);
+ if (!cont) {
+ cont = [];
+ }
+
+ const notified = new Promise(resolve => {
+ cont.push(resolve);
+ });
+
+ this.conts.set(id, cont);
+
+ await notified;
+ }
+}
+
+const notifier = new Notifications();
let messageTid = 0;
const workerProcesses = new Map();
+const workersCount = config.queue.processes;
const idleWorkers = [];
let campaignSchedulerRunning = false;
let queuedSchedulerRunning = false;
-let workerSchedulerRunning = false;
-const campaignsCheckPeriod = 5 * 1000;
+const campaignsCheckPeriod = 30 * 1000;
const retrieveBatchSize = 1000;
-const workerBatchSize = 100;
+const workerBatchSize = 10;
-const messageQueue = new Map(); // campaignId -> [{listId, email}]
-const messageQueueCont = new Map(); // campaignId -> next batch callback
-const campaignFinishCont = new Map(); // campaignId -> worker finished callback
+const sendConfigurationMessageQueue = new Map(); // sendConfigurationId -> [{queuedMessage}]
+const campaignMessageQueue = new Map(); // campaignId -> [{listId, email}]
-const workAssignment = new Map(); // workerId -> { campaignId, subscribers: [{listId, email}] }
-
-let workerSchedulerCont = null;
-let queuedLastId = 0;
+const workAssignment = new Map(); // workerId -> { campaignId, messages: [{listId, email} } / { sendConfigurationId, messages: [{queuedMessage}] }
function messagesProcessed(workerId) {
@@ -43,108 +71,151 @@ function messagesProcessed(workerId) {
workAssignment.delete(workerId);
idleWorkers.push(workerId);
- if (workerSchedulerCont) {
- const cont = workerSchedulerCont;
- setImmediate(workerSchedulerCont);
- workerSchedulerCont = null;
- }
-
- if (campaignFinishCont.has(wa.campaignId)) {
- setImmediate(campaignFinishCont.get(wa.campaignId));
- campaignFinishCont.delete(wa.campaignId);
- }
+ notifier.notify('workerFinished');
}
-async function scheduleWorkers() {
+async function workersLoop() {
+ const reservedWorkersForTestCount = workersCount > 1 ? 1 : 0;
+
async function getAvailableWorker() {
- if (idleWorkers.length > 0) {
- return idleWorkers.shift();
-
- } else {
- const workerAvailable = new Promise(resolve => {
- workerSchedulerCont = resolve;
- });
-
- await workerAvailable;
- return idleWorkers.shift();
+ while (idleWorkers.length === 0) {
+ await notifier.waitFor('workerFinished');
}
+
+ return idleWorkers.shift();
}
+ function assignCampaignTaskToWorker(workerId, task) {
+ const campaignId = task.campaignId;
+ const queue = task.queue;
- if (workerSchedulerRunning) {
- return;
+ const messages = queue.splice(0, workerBatchSize);
+ workAssignment.set(workerId, {campaignId, messages});
+
+ if (queue.length === 0) {
+ notifier.notify(`campaignMessageQueueEmpty:${campaignId}`);
+ }
+
+ sendToWorker(workerId, 'process-campaign-messages', {
+ campaignId,
+ messages
+ });
}
- workerSchedulerRunning = true;
- let workerId = await getAvailableWorker();
+ function assignSendConfigurationTaskToWorker(workerId, task) {
+ const sendConfigurationId = task.sendConfigurationId;
+ const queue = task.queue;
- let keepLooping = true;
+ const messages = queue.splice(0, workerBatchSize);
+ workAssignment.set(workerId, {sendConfigurationId, messages});
- while (keepLooping) {
- keepLooping = false;
+ if (queue.length === 0) {
+ notifier.notify(`sendConfigurationMessageQueueEmpty:${sendConfigurationId}`);
+ }
- for (const campaignId of messageQueue.keys()) {
- const queue = messageQueue.get(campaignId);
+ sendToWorker(workerId, 'process-queued-messages', {
+ sendConfigurationId,
+ messages
+ });
+ }
- if (queue.length > 0) {
- const subscribers = queue.splice(0, workerBatchSize);
- workAssignment.set(workerId, {campaignId, subscribers});
+ function selectNextTask() {
+ const allocationMap = new Map();
+ const allocation = [];
- if (queue.length === 0 && messageQueueCont.has(campaignId)) {
- setImmediate(messageQueueCont.get(campaignId));
- messageQueueCont.delete(campaignId);
+ function initAllocation(attrName, queues, assignWorkerHandler) {
+ for (const id of queues.keys()) {
+ const key = attrName + ':' + id;
+
+ const queue = queues.get(id);
+
+ const task = {
+ [attrName]: id,
+ existingWorkers: 0,
+ isEmpty: queue.length === 0,
+ queue,
+ assignWorkerHandler
+ };
+
+ allocationMap.set(key, task);
+ allocation.push(task);
+ }
+
+ for (const wa of workAssignment.values()) {
+ if (wa[attrName]) {
+ const key = attrName + ':' + wa[attrName];
+ const task = allocationMap.get(key);
+ task.existingWorkers += 1;
}
-
- sendToWorker(workerId, 'process-messages', {
- campaignId,
- subscribers
- });
- workerId = await getAvailableWorker();
-
- keepLooping = true;
}
}
+
+ initAllocation('sendConfigurationId', sendConfigurationMessageQueue, assignSendConfigurationTaskToWorker);
+ initAllocation('campaignId', campaignMessageQueue, assignCampaignTaskToWorker);
+
+ let minTask = null;
+ let minExistingWorkers;
+
+ for (const task of allocation) {
+ if (!task.isEmpty && (minTask === null || minExistingWorkers > task.existingWorkers)) {
+ minTask = task;
+ minExistingWorkers = task.existingWorkers;
+ }
+ }
+
+ return minTask;
}
- idleWorkers.push(workerId);
- workerSchedulerRunning = false;
+ while (true) {
+ const task = selectNextTask();
+
+ if (task) {
+ const workerId = await getAvailableWorker();
+ task.assignWorkerHandler(workerId, task);
+
+ } else {
+ await notifier.waitFor('workAvailable');
+ }
+ }
}
async function processCampaign(campaignId) {
- async function finish() {
- let workerRunning = false;
- for (const wa of workAssignment.values()) {
- if (wa.campaignId === campaignId) {
- workerRunning = true;
+ const msgQueue = campaignMessageQueue.get(campaignId);
+
+ async function finish(newStatus) {
+ const isCompleted = () => {
+ if (msgQueue.length > 0) return false;
+
+ let workerRunning = false;
+
+ for (const wa of workAssignment.values()) {
+ if (wa.campaignId === campaignId) {
+ workerRunning = true;
+ }
}
+
+ return !workerRunning;
+ };
+
+ while (!isCompleted()) {
+ await notifier.waitFor('workerFinished');
}
- if (workerRunning) {
- const workerFinished = new Promise(resolve => {
- campaignFinishCont.set(campaignId, resolve);
- });
+ campaignMessageQueue.delete(campaignId);
- await workerFinished;
- setImmediate(finish);
- }
-
- await knex('campaigns').where('id', campaignId).update({status: CampaignStatus.FINISHED});
- await activityLog.logEntityActivity('campaign', CampaignActivityType.STATUS_CHANGE, campaignId, {status: CampaignStatus.FINISHED});
-
- messageQueue.delete(campaignId);
+ await knex('campaigns').where('id', campaignId).update({status: newStatus});
+ await activityLog.logEntityActivity('campaign', CampaignActivityType.STATUS_CHANGE, campaignId, {status: newStatus});
}
- const msgQueue = [];
- messageQueue.set(campaignId, msgQueue);
-
try {
while (true) {
const cpg = await knex('campaigns').where('id', campaignId).first();
- if (cpg.status === CampaignStatus.PAUSED) {
- messageQueue.delete(campaignId);
+ if (cpg.status === CampaignStatus.PAUSING) {
+ msgQueue.splice(0);
+ await finish(CampaignStatus.PAUSED);
return;
}
@@ -154,21 +225,21 @@ async function processCampaign(campaignId) {
});
if (qryGen) {
- let subscribersInProcessing = [...msgQueue];
+ let messagesInProcessing = [...msgQueue];
for (const wa of workAssignment.values()) {
if (wa.campaignId === campaignId) {
- subscribersInProcessing = subscribersInProcessing.concat(wa.subscribers);
+ messagesInProcessing = messagesInProcessing.concat(wa.messages);
}
}
const qry = qryGen(knex)
- .whereNotIn('pending_subscriptions.email', subscribersInProcessing.map(x => x.email))
+ .whereNotIn('pending_subscriptions.email', messagesInProcessing.map(x => x.email))
.select(['pending_subscriptions.email', 'campaign_lists.list'])
.limit(retrieveBatchSize);
const subs = await qry;
if (subs.length === 0) {
- await finish();
+ await finish(CampaignStatus.FINISHED);
return;
}
@@ -179,21 +250,19 @@ async function processCampaign(campaignId) {
});
}
- const nextBatchNeeded = new Promise(resolve => {
- messageQueueCont.set(campaignId, resolve);
- });
+ notifier.notify('workAvailable');
- setImmediate(scheduleWorkers);
-
- await nextBatchNeeded;
+ while (msgQueue.length > 0) {
+ await notifier.waitFor(`campaignMessageQueueEmpty:${campaignId}`);
+ }
} else {
- await finish();
+ await finish(CampaignStatus.FINISHED);
return;
}
}
} catch (err) {
- log.error('Senders', `Sending campaign ${campaignId} failed with error: ${err.message}`)
+ log.error('Senders', `Sending campaign ${campaignId} failed with error: ${err.message}`);
log.verbose(err.stack);
}
}
@@ -226,6 +295,8 @@ async function scheduleCampaigns() {
});
if (campaignId) {
+ campaignMessageQueue.set(campaignId, []);
+
// noinspection JSIgnoredPromiseFromCall
processCampaign(campaignId);
@@ -234,16 +305,81 @@ async function scheduleCampaigns() {
}
}
} catch (err) {
- log.error('Senders', `Scheduling campaigns failed with error: ${err.message}`)
+ log.error('Senders', `Scheduling campaigns failed with error: ${err.message}`);
log.verbose(err.stack);
}
-
campaignSchedulerRunning = false;
}
-async function processQueued() {
+async function processQueuedBySendConfiguration(sendConfigurationId) {
+ const msgQueue = sendConfigurationMessageQueue.get(sendConfigurationId);
+
+ const isCompleted = () => {
+ if (msgQueue.length > 0) return false;
+
+ let workerRunning = false;
+
+ for (const wa of workAssignment.values()) {
+ if (wa.sendConfigurationId === sendConfigurationId) {
+ workerRunning = true;
+ }
+ }
+
+ return !workerRunning;
+ };
+
+ try {
+ while (true) {
+ let messagesInProcessing = [...msgQueue];
+ for (const wa of workAssignment.values()) {
+ if (wa.sendConfigurationId === sendConfigurationId) {
+ messagesInProcessing = messagesInProcessing.concat(wa.messages);
+ }
+ }
+
+ const rows = await knex('queued')
+ .orderByRaw(`FIELD(type, ${MessageType.TRIGGERED}, ${MessageType.TEST}) DESC, id ASC`) // This orders MessageType.TEST messages before MessageType.TRIGGERED ones
+ .where('send_configuration', sendConfigurationId)
+ .whereNotIn('id', messagesInProcessing.map(x => x.queuedMessage.id))
+ .limit(retrieveBatchSize);
+
+ if (rows.length === 0) {
+ if (isCompleted()) {
+ sendConfigurationMessageQueue.delete(sendConfigurationId);
+ return;
+
+ } else {
+ while (!isCompleted()) {
+ await notifier.waitFor('workerFinished');
+ }
+
+ // At this point, there might be new messages in the queued that could belong to us. Thus we have to try again instead for returning.
+ continue;
+ }
+ }
+
+ for (const row of rows) {
+ row.data = JSON.parse(row.data);
+ msgQueue.push({
+ queuedMessage: row
+ });
+ }
+
+ notifier.notify('workAvailable');
+
+ while (msgQueue.length > 0) {
+ await notifier.waitFor(`sendConfigurationMessageQueueEmpty:${sendConfigurationId}`);
+ }
+ }
+ } catch (err) {
+ log.error('Senders', `Sending queued messages for send configuration ${sendConfigurationId} failed with error: ${err.message}`);
+ log.verbose(err.stack);
+ }
+}
+
+async function scheduleQueued() {
if (queuedSchedulerRunning) {
return;
}
@@ -252,34 +388,23 @@ async function processQueued() {
try {
while (true) {
- const rows = await knex('queued')
- .orderBy('id', 'asc')
- .where('id', '>', queuedLastId)
- .limit(retrieveBatchSize);
+ const sendConfigurationsInProcessing = [...sendConfigurationMessageQueue.keys()];
- if (rows.length === 0) {
- break;
- }
+ const rows = await knex('queued')
+ .whereNotIn('send_configuration', sendConfigurationsInProcessing)
+ .groupBy('send_configuration')
+ .select(['send_configuration']);
for (const row of rows) {
- let msgQueue = messageQueue.get(row.campaign);
- if (!msgQueue) {
- msgQueue = [];
- messageQueue.set(row.campaign, msgQueue);
- }
+ const sendConfigurationId = row.send_configuration;
+ sendConfigurationMessageQueue.set(sendConfigurationId, []);
- msgQueue.push({
- listId: row.list,
- subscriptionId: row.subscription
- });
+ // noinspection JSIgnoredPromiseFromCall
+ processQueuedBySendConfiguration(sendConfigurationId);
}
-
- queuedLastId = rows[rows.length - 1].id;
-
- setImmediate(scheduleWorkers);
}
} catch (err) {
- log.error('Senders', `Processing queued messages failed with error: ${err.message}`)
+ log.error('Senders', `Scheduling queued messages failed with error: ${err.message}`);
log.verbose(err.stack);
}
@@ -337,7 +462,7 @@ function periodicCampaignsCheck() {
scheduleCampaigns();
// noinspection JSIgnoredPromiseFromCall
- processQueued();
+ scheduleQueued();
setTimeout(periodicCampaignsCheck, campaignsCheckPeriod);
}
@@ -345,7 +470,7 @@ function periodicCampaignsCheck() {
async function init() {
const spawnWorkerFutures = [];
let workerId;
- for (workerId = 0; workerId < config.queue.processes; workerId++) {
+ for (workerId = 0; workerId < workersCount; workerId++) {
spawnWorkerFutures.push(spawnWorker(workerId));
}
@@ -358,6 +483,7 @@ async function init() {
if (type === 'schedule-check') {
// noinspection JSIgnoredPromiseFromCall
scheduleCampaigns();
+ scheduleQueued();
} else if (type === 'reload-config') {
for (const workerId of workerProcesses.keys()) {
@@ -376,6 +502,8 @@ async function init() {
});
periodicCampaignsCheck();
+
+ setImmediate(workersLoop);
}
// noinspection JSIgnoredPromiseFromCall
diff --git a/server/services/sender-worker.js b/server/services/sender-worker.js
index cbe7750f..a9f4c3d8 100644
--- a/server/services/sender-worker.js
+++ b/server/services/sender-worker.js
@@ -3,14 +3,13 @@
const config = require('config');
const log = require('../lib/log');
const mailers = require('../lib/mailers');
-const CampaignSender = require('../lib/campaign-sender');
-const {enforce} = require('../lib/helpers');
+const {CampaignSender} = require('../lib/campaign-sender');
require('../lib/fork');
const workerId = Number.parseInt(process.argv[2]);
let running = false;
-async function processMessages(campaignId, subscribers) {
+async function processCampaignMessages(campaignId, messages) {
if (running) {
log.error('Senders', `Worker ${workerId} assigned work while working`);
return;
@@ -19,23 +18,40 @@ async function processMessages(campaignId, subscribers) {
running = true;
const cs = new CampaignSender();
- await cs.init({campaignId})
+ await cs.initByCampaignId(campaignId);
- for (const subData of subscribers) {
+ for (const msgData of messages) {
try {
- if (subData.email) {
- await cs.sendMessageByEmail(subData.listId, subData.email);
+ await cs.sendRegularMessage(msgData.listId, msgData.email);
- } else if (subData.subscriptionId) {
- await cs.sendMessageBySubscriptionId(subData.listId, subData.subscriptionId);
-
- } else {
- enforce(false);
- }
-
- log.verbose('Senders', 'Message sent and status updated for %s:%s', subData.listId, subData.email || subData.subscriptionId);
+ log.verbose('Senders', 'Message sent and status updated for %s:%s', msgData.listId, msgData.email);
} catch (err) {
- log.error('Senders', `Sending message to ${subData.listId}:${subData.email} failed with error: ${err.message}`)
+ log.error('Senders', `Sending message to ${msgData.listId}:${msgData.email} failed with error: ${err.message}`);
+ log.verbose(err.stack);
+ }
+ }
+
+ running = false;
+
+ sendToMaster('messages-processed');
+}
+
+async function processQueuedMessages(sendConfigurationId, messages) {
+ if (running) {
+ log.error('Senders', `Worker ${workerId} assigned work while working`);
+ return;
+ }
+
+ running = true;
+
+ for (const msgData of messages) {
+ const queuedMessage = msgData.queuedMessage;
+ try {
+ await CampaignSender.sendQueuedMessage(queuedMessage);
+
+ log.verbose('Senders', 'Message sent and status updated for %s:%s', queuedMessage.list, queuedMessage.subscription);
+ } catch (err) {
+ log.error('Senders', `Sending message to ${queuedMessage.list}:${queuedMessage.subscription} failed with error: ${err.message}`);
log.verbose(err.stack);
}
}
@@ -58,11 +74,14 @@ process.on('message', msg => {
if (type === 'reload-config') {
mailers.invalidateMailer(msg.data.sendConfigurationId);
- } else if (type === 'process-messages') {
+ } else if (type === 'process-campaign-messages') {
// noinspection JSIgnoredPromiseFromCall
- processMessages(msg.data.campaignId, msg.data.subscribers)
- }
+ processCampaignMessages(msg.data.campaignId, msg.data.messages)
+ } else if (type === 'process-queued-messages') {
+ // noinspection JSIgnoredPromiseFromCall
+ processQueuedMessages(msg.data.sendConfigurationId, msg.data.messages)
+ }
}
});
diff --git a/server/services/triggers.js b/server/services/triggers.js
index a3f11de9..3689f425 100644
--- a/server/services/triggers.js
+++ b/server/services/triggers.js
@@ -10,6 +10,7 @@ const { Entity, Event } = require('../../shared/triggers');
const { SubscriptionStatus } = require('../../shared/lists');
const links = require('../models/links');
const contextHelpers = require('../lib/context-helpers');
+const {MessageType, CampaignSender} = require('../lib/campaign-sender');
const triggerCheckPeriod = 30 * 1000;
const triggerFirePeriod = 120 * 1000;
@@ -151,12 +152,13 @@ async function run() {
subscription: subscriber.id
});
- await tx('queued').insert({
- campaign: campaign.id,
- list: cpgList.list,
- subscription: subscriber.id,
- trigger: trigger.id
- });
+ await CampaignSender.queueMessageTx(tx,
+ campaign.send_configuration, cpgList.list, subscriber.id, MessageType.TRIGGERED,
+ {
+ campaignId: campaign.id,
+ triggerId: trigger.id
+ }
+ );
await tx('triggers').increment('count').where('id', trigger.id);
diff --git a/server/setup/knex/migrations/20170506102634_v1_to_v2.js b/server/setup/knex/migrations/20170506102634_v1_to_v2.js
index 05369236..9e1c2b45 100644
--- a/server/setup/knex/migrations/20170506102634_v1_to_v2.js
+++ b/server/setup/knex/migrations/20170506102634_v1_to_v2.js
@@ -753,11 +753,11 @@ async function migrateSettings(knex) {
if (settings.dkimApiKey) {
mailer_type = MailerType.ZONE_MTA;
- mailer_settings.dkimApiKey = settings.dkimApiKey;
+ mailer_settings.dkimApiKey = settings.dkimApiKey || '';
mailer_settings.zoneMtaType = ZoneMTAType.WITH_HTTP_CONF;
- mailer_settings.dkimDomain = settings.dkimDomain;
- mailer_settings.dkimSelector = settings.dkimSelector;
- mailer_settings.dkimPrivateKey = settings.dkimPrivateKey;
+ mailer_settings.dkimDomain = settings.dkimDomain || '';
+ mailer_settings.dkimSelector = settings.dkimSelector || '';
+ mailer_settings.dkimPrivateKey = settings.dkimPrivateKey || '';
}
}
@@ -777,7 +777,7 @@ async function migrateSettings(knex) {
verp_hostname: settings.verpUse ? settings.verpHostname : null,
mailer_type,
mailer_settings: JSON.stringify(mailer_settings),
- x_mailer: settings.x_mailer,
+ x_mailer: settings.x_mailer || '',
namespace: getGlobalNamespaceId()
});
@@ -810,7 +810,7 @@ async function addFiles(knex) {
table.string('mimetype');
table.integer('size');
table.timestamp('created').defaultTo(knex.fn.now());
- table.index(['entity', 'originalname'])
+ table.index(['entity', 'originalname']);
});
}
}
diff --git a/server/setup/knex/migrations/20190615000000_generalization_of_queued_and_file_locking.js b/server/setup/knex/migrations/20190615000000_generalization_of_queued_and_file_locking.js
new file mode 100644
index 00000000..99b2c6d1
--- /dev/null
+++ b/server/setup/knex/migrations/20190615000000_generalization_of_queued_and_file_locking.js
@@ -0,0 +1,63 @@
+const entityTypesWithFiles = {
+ campaign: {
+ file: 'files_campaign_file',
+ attachment: 'files_campaign_attachment',
+ },
+ template: {
+ file: 'files_template_file'
+ },
+ mosaico_template: {
+ file: 'files_mosaico_template_file',
+ block: 'files_mosaico_template_block'
+ }
+};
+
+exports.up = (knex, Promise) => (async() => {
+ await knex.schema.table('queued', table => {
+ table.integer('send_configuration').unsigned().notNullable();
+ table.integer('type').unsigned().notNullable(); // The values come from campaign-sender.js:MessageType
+ table.text('data', 'longtext');
+ });
+
+ const queued = await knex('queued')
+ .leftJoin('campaigns', 'queued.campaign', 'campaigns.id')
+ .select(['queued.id', 'queued.trigger', 'queued.campaign', 'campaigns.send_configuration']);
+
+ for (const queuedEntry of queued) {
+ const data = {};
+
+ if (queued.trigger) {
+ data.triggerId = queuedEntry.trigger;
+ data.campaignId = queuedEntry.campaign;
+ }
+
+ knex('queued')
+ .where('id', queuedEntry.id)
+ .update({
+ send_configuration: queuedEntry.send_configuration,
+ data: JSON.stringify(data)
+ });
+ }
+
+ await knex.schema.table('queued', table => {
+ table.dropColumn('trigger');
+ table.dropColumn('campaign');
+ });
+
+
+ for (const type in entityTypesWithFiles) {
+ const typeEntry = entityTypesWithFiles[type];
+
+ for (const subType in typeEntry) {
+ const subTypeEntry = typeEntry[subType];
+
+ await knex.schema.table(subTypeEntry, table => {
+ table.boolean('delete_pending').notNullable().defaultTo(false);
+ table.integer('lock_count').notNullable().defaultTo(0);
+ });
+ }
+ }
+})();
+
+exports.down = (knex, Promise) => (async() => {
+})();
diff --git a/server/setup/knex/migrations/20190616000000_drop_subject_in_send_configurations.js b/server/setup/knex/migrations/20190616000000_drop_subject_in_send_configurations.js
new file mode 100644
index 00000000..862a8bad
--- /dev/null
+++ b/server/setup/knex/migrations/20190616000000_drop_subject_in_send_configurations.js
@@ -0,0 +1,15 @@
+exports.up = (knex, Promise) => (async() => {
+ await knex.schema.table('send_configurations', table => {
+ table.dropColumn('subject');
+ table.dropColumn('subject_overridable');
+ });
+
+ await knex.schema.table('campaigns', table => {
+ table.renameColumn('subject_override', 'subject');
+ });
+
+ await knex('campaigns').whereNull('subject').update('subject', '');
+})();
+
+exports.down = (knex, Promise) => (async() => {
+})();
diff --git a/shared/activity-log.js b/shared/activity-log.js
index ea029f97..55ea6c63 100644
--- a/shared/activity-log.js
+++ b/shared/activity-log.js
@@ -8,7 +8,8 @@ const EntityActivityType = {
};
const CampaignActivityType = {
- STATUS_CHANGE: EntityActivityType.MAX + 1
+ STATUS_CHANGE: EntityActivityType.MAX + 1,
+ TEST_SEND: EntityActivityType.MAX + 2,
};
const ListActivityType = {
diff --git a/shared/campaigns.js b/shared/campaigns.js
index 43d7f28b..ccdbd4b7 100644
--- a/shared/campaigns.js
+++ b/shared/campaigns.js
@@ -38,11 +38,12 @@ const CampaignStatus = {
// For campaign types: NORMAL, RSS_ENTRY
SENDING: 7,
+ PAUSING: 8,
- MAX: 8
+ MAX: 9
};
-const campaignOverridables = ['from_name', 'from_email', 'reply_to', 'subject'];
+const campaignOverridables = ['from_name', 'from_email', 'reply_to'];
function getSendConfigurationPermissionRequiredForSend(campaign, sendConfiguration) {
let allowedOverride = false;
From a9039e5760c5c9ce3fbd2836fbe710f93b457aad Mon Sep 17 00:00:00 2001
From: Tomas Bures
Date: Tue, 25 Jun 2019 12:17:30 +0200
Subject: [PATCH 03/29] Fix - subject line is not shown when send configuration
is not selected
---
client/src/campaigns/CUD.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/client/src/campaigns/CUD.js b/client/src/campaigns/CUD.js
index 37a27284..09f82d27 100644
--- a/client/src/campaigns/CUD.js
+++ b/client/src/campaigns/CUD.js
@@ -631,8 +631,6 @@ export default class CUD extends Component {
addOverridable('from_name', t('fromName'));
addOverridable('from_email', t('fromEmailAddress'));
addOverridable('reply_to', t('replytoEmailAddress'));
-
- sendSettings.push();
} else {
sendSettings = {t('loadingSendConfiguration')}
}
@@ -747,6 +745,8 @@ export default class CUD extends Component {
{sendSettings}
+
+
From 355e03900a2962ff54c784be102a5df6f08715bc Mon Sep 17 00:00:00 2001
From: Tomas Bures
Date: Tue, 25 Jun 2019 12:35:04 +0200
Subject: [PATCH 04/29] Fix - subject line is not saved
---
client/src/campaigns/CUD.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/campaigns/CUD.js b/client/src/campaigns/CUD.js
index 09f82d27..d0398a26 100644
--- a/client/src/campaigns/CUD.js
+++ b/client/src/campaigns/CUD.js
@@ -244,7 +244,7 @@ export default class CUD extends Component {
return filterData(data, [
'name', 'description', 'segment', 'namespace', 'send_configuration',
- 'from_name_override', 'from_email_override', 'reply_to_override',
+ 'subject', 'from_name_override', 'from_email_override', 'reply_to_override',
'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url',
'type', 'source', 'parent', 'lists'
]);
From 4e9f6bd57bcfd48838bc70ee90c1ce94a161781b Mon Sep 17 00:00:00 2001
From: Tomas Bures
Date: Sat, 29 Jun 2019 23:19:56 +0200
Subject: [PATCH 05/29] Work in progress on refactoring all mail sending to use
the message sender an sender workers. No yet finished.
---
client/src/campaigns/CUD.js | 2 +-
client/src/campaigns/Status.js | 11 +-
server/config/default.yaml | 15 +
server/lib/builtin-zone-mta.js | 92 ++--
server/lib/knex.js | 6 +
server/lib/mailers.js | 81 +---
.../{campaign-sender.js => message-sender.js} | 432 ++++++++++--------
server/lib/senders.js | 1 +
server/lib/subscription-mail-helpers.js | 26 +-
server/lib/tools.js | 10 +-
server/models/campaigns.js | 45 +-
server/models/confirmations.js | 2 +-
server/models/subscriptions.js | 2 +-
server/models/templates.js | 2 +-
server/models/users.js | 38 +-
server/routes/archive.js | 4 +-
server/services/sender-master.js | 385 ++++++++++++----
server/services/sender-worker.js | 42 +-
server/services/triggers.js | 6 +-
...neralization_of_queued_and_file_locking.js | 2 +-
...0190629000000_add_start_at_to_campaigns.js | 26 ++
...20190629170000_generalization_of_queued.js | 25 +
22 files changed, 811 insertions(+), 444 deletions(-)
rename server/lib/{campaign-sender.js => message-sender.js} (59%)
create mode 100644 server/setup/knex/migrations/20190629000000_add_start_at_to_campaigns.js
create mode 100644 server/setup/knex/migrations/20190629170000_generalization_of_queued.js
diff --git a/client/src/campaigns/CUD.js b/client/src/campaigns/CUD.js
index d0398a26..c37a195f 100644
--- a/client/src/campaigns/CUD.js
+++ b/client/src/campaigns/CUD.js
@@ -257,7 +257,7 @@ export default class CUD extends Component {
if (this.props.entity.status === CampaignStatus.SENDING) {
this.disableForm();
}
-
+
} else {
const data = {};
for (const overridable of campaignOverridables) {
diff --git a/client/src/campaigns/Status.js b/client/src/campaigns/Status.js
index 5f70fc7f..c0965af3 100644
--- a/client/src/campaigns/Status.js
+++ b/client/src/campaigns/Status.js
@@ -323,11 +323,11 @@ class SendControls extends Component {
{this.getFormValue('sendLater') ?
-
+
:
-
+
}
- {entity.status === CampaignStatus.PAUSED && }
+ {entity.status === CampaignStatus.PAUSED && }
{entity.status === CampaignStatus.PAUSED && }
{testButtons}
@@ -341,6 +341,7 @@ class SendControls extends Component {
{t('Campaign is being paused. Please wait.')}
+
{testButtons}
@@ -354,7 +355,7 @@ class SendControls extends Component {
{t('campaignIsBeingSentOut')}
-
+
{testButtons}
@@ -371,7 +372,7 @@ class SendControls extends Component {
-
+
{testButtons}
diff --git a/server/config/default.yaml b/server/config/default.yaml
index 6b229ab9..f400f2ef 100644
--- a/server/config/default.yaml
+++ b/server/config/default.yaml
@@ -165,6 +165,21 @@ queue:
# How many parallel sender processes to spawn
processes: 2
+ # For how long (in seconds) to try to send an email before Mailtrain stops to trying. An email can normally
+ # be sent out almost immediately. However if the send configuration is not correct or the mail server is not reachable,
+ # Mailtrain will keep retrying until the email expires.
+ # Due to Mailtrain's internal timeouts, the values should be at least 60 seconds.
+ retention:
+ # Regular and RSS campaign. Once this expires, the campaign is considered finished. The remaining recipients
+ # are included in the set of those recipients to whom the message would be delivered if the campaign is again started.
+ campaign: 86400 # 1 day
+ # Triggered campaign. Once this expires, the message gets discarded.
+ triggered: 86400 # 1 day
+ # Test send (in campaign or template)
+ test: 300 # 5 minutes
+ # Subscription and password reset related emails
+ subscription: 300 # 5 minutes
+
cors:
# Allow subscription widgets to be embedded
# origins: ['https://www.example.com']
diff --git a/server/lib/builtin-zone-mta.js b/server/lib/builtin-zone-mta.js
index 06db0660..1e820f0c 100644
--- a/server/lib/builtin-zone-mta.js
+++ b/server/lib/builtin-zone-mta.js
@@ -4,17 +4,31 @@ const config = require('config');
const fork = require('./fork').fork;
const log = require('./log');
const path = require('path');
-const fs = require('fs-extra')
+const fs = require('fs-extra');
const crypto = require('crypto');
const bluebird = require('bluebird');
-let zoneMtaProcess;
+let zoneMtaProcess = null;
const zoneMtaDir = path.join(__dirname, '..', '..', 'zone-mta');
const zoneMtaBuiltingConfig = path.join(zoneMtaDir, 'config', 'builtin-zonemta.json');
const password = process.env.BUILTIN_ZONE_MTA_PASSWORD || crypto.randomBytes(20).toString('hex').toLowerCase();
+let restartCount = 0;
+let lastRestartCount = 0;
+
+let restartBackoffIdx = 0;
+const restartBackoff = [0, 30, 60, 300]; // in seconds
+
+setInterval(() => {
+ if (restartCount === lastRestartCount) {
+ restartBackoffIdx = 0;
+ }
+
+ lastRestartCount = restartCount;
+}, 300000 /* 5 mins */);
+
function getUsername() {
return 'mailtrain';
}
@@ -119,36 +133,58 @@ async function createConfig() {
await fs.writeFile(zoneMtaBuiltingConfig, JSON.stringify(cnf, null, 2));
}
+function restart(callback) {
+ if (zoneMtaProcess) return callback();
+
+ if (restartCount === 0) {
+ log.info('ZoneMTA', 'Starting built-in Zone MTA process');
+ } else {
+ log.info('ZoneMTA', `Restarting built-in Zone MTA process (restart count ${restartCount})`);
+ }
+
+ zoneMtaProcess = fork(
+ path.join(zoneMtaDir, 'index.js'),
+ ['--config=' + zoneMtaBuiltingConfig],
+ {
+ cwd: zoneMtaDir,
+ env: {NODE_ENV: process.env.NODE_ENV}
+ }
+ );
+
+ zoneMtaProcess.on('message', msg => {
+ if (msg) {
+ if (msg.type === 'zone-mta-started') {
+ log.info('ZoneMTA', 'ZoneMTA process started');
+
+ if (callback) {
+ return callback();
+ } else {
+ return;
+ }
+ }
+ }
+ });
+
+ zoneMtaProcess.on('close', (code, signal) => {
+ log.error('ZoneMTA', 'ZoneMTA process exited with code %s signal %s', code, signal);
+
+ zoneMtaProcess = null;
+ restartCount += 1;
+
+ const backoffTimeout = restartBackoff[restartBackoffIdx] * 1000;
+ if (restartBackoffIdx < restartBackoff.length - 1) {
+ restartBackoffIdx += 1;
+ }
+
+ setTimeout(restart, backoffTimeout);
+ });
+}
+
function spawn(callback) {
if (config.builtinZoneMTA.enabled) {
createConfig().then(() => {
- log.info('ZoneMTA', 'Starting built-in Zone MTA process');
-
- zoneMtaProcess = fork(
- path.join(zoneMtaDir, 'index.js'),
- ['--config=' + zoneMtaBuiltingConfig],
- {
- cwd: zoneMtaDir,
- env: {NODE_ENV: process.env.NODE_ENV}
- }
- );
-
- zoneMtaProcess.on('message', msg => {
- if (msg) {
- if (msg.type === 'zone-mta-started') {
- log.info('ZoneMTA', 'ZoneMTA process started');
- return callback();
- } else if (msg.type === 'entries-added') {
- senders.scheduleCheck();
- }
- }
- });
-
- zoneMtaProcess.on('close', (code, signal) => {
- log.error('ZoneMTA', 'ZoneMTA process exited with code %s signal %s', code, signal);
- });
-
+ restart(callback);
}).catch(err => callback(err));
} else {
diff --git a/server/lib/knex.js b/server/lib/knex.js
index 045b73cb..578fd2bd 100644
--- a/server/lib/knex.js
+++ b/server/lib/knex.js
@@ -23,6 +23,12 @@ const knex = require('knex')({
//, debug: true
});
+/*
+This is to enable logging on mysql side:
+SET GLOBAL general_log = 'ON';
+SET GLOBAL general_log_file = '/tmp/mysql-all.log';
+*/
+
module.exports = knex;
diff --git a/server/lib/mailers.js b/server/lib/mailers.js
index e9faf41a..74a61d34 100644
--- a/server/lib/mailers.js
+++ b/server/lib/mailers.js
@@ -3,8 +3,6 @@
const log = require('./log');
const config = require('config');
-const Handlebars = require('handlebars');
-const util = require('util');
const nodemailer = require('nodemailer');
const aws = require('aws-sdk');
const openpgpEncrypt = require('nodemailer-openpgp').openpgpEncrypt;
@@ -14,13 +12,21 @@ const builtinZoneMta = require('./builtin-zone-mta');
const contextHelpers = require('./context-helpers');
const settings = require('../models/settings');
-const tools = require('./tools');
-const htmlToText = require('html-to-text');
const bluebird = require('bluebird');
const transports = new Map();
+class SendConfigurationError extends Error {
+ constructor(sendConfigurationId, ...args) {
+ super(...args);
+ this.sendConfigurationId = sendConfigurationId;
+ Error.captureStackTrace(this, SendConfigurationError);
+ }
+}
+
+
+
async function getOrCreateMailer(sendConfigurationId) {
let sendConfiguration;
@@ -73,25 +79,18 @@ function _addDkimKeys(transport, mail) {
async function _sendMail(transport, mail, template) {
_addDkimKeys(transport, mail);
- let tryCount = 0;
- const trySend = (callback) => {
- tryCount++;
- transport.sendMail(mail, (err, info) => {
- if (err) {
- log.error('Mail', err);
- if (err.responseCode && err.responseCode >= 400 && err.responseCode < 500 && tryCount <= 5) {
- // temporary error, try again
- log.verbose('Mail', 'Retrying after %s sec. ...', tryCount);
- return setTimeout(trySend, tryCount * 1000);
- }
- return callback(err);
- }
- return callback(null, info);
- });
- };
+ try {
+ return await transport.sendMailAsync(mail);
- const trySendAsync = bluebird.promisify(trySend);
- return await trySendAsync();
+ } catch (err) {
+ if ( (err.responseCode && err.responseCode >= 400 && err.responseCode < 500) ||
+ (err.code === 'ECONNECTION' && err.errno === 'ECONNREFUSED')
+ ) {
+ throw new SendConfigurationError(transport.mailer.sendConfiguration.id, 'Cannot connect to service specified by send configuration ' + transport.mailer.sendConfiguration.id);
+ }
+
+ throw err;
+ }
}
async function _sendTransactionalMail(transport, mail) {
@@ -103,39 +102,6 @@ async function _sendTransactionalMail(transport, mail) {
return await _sendMail(transport, mail);
}
-async function _sendTransactionalMailBasedOnTemplate(transport, mail, template) {
- const sendConfiguration = transport.mailer.sendConfiguration;
-
- mail.from = {
- name: sendConfiguration.from_name,
- address: sendConfiguration.from_email
- };
-
- const htmlRenderer = await tools.getTemplate(template.html, template.locale);
-
- if (htmlRenderer) {
- mail.html = htmlRenderer(template.data || {});
- }
-
- const preparedHtml = await tools.prepareHtml(mail.html);
-
- if (preparedHtml) {
- mail.html = preparedHtml;
- }
-
- const textRenderer = await tools.getTemplate(template.text, template.locale);
-
- if (textRenderer) {
- mail.text = textRenderer(template.data || {});
- } else if (mail.html) {
- mail.text = htmlToText.fromString(mail.html, {
- wordwrap: 130
- });
- }
-
- return await _sendTransactionalMail(transport, mail);
-}
-
async function _createTransport(sendConfiguration) {
const mailerSettings = sendConfiguration.mailer_settings;
const mailerType = sendConfiguration.mailer_type;
@@ -222,6 +188,7 @@ async function _createTransport(sendConfiguration) {
}
const transport = nodemailer.createTransport(transportOptions, config.nodemailer);
+ transport.sendMailAsync = bluebird.promisify(transport.sendMail.bind(transport));
transport.use('stream', openpgpEncrypt({
signingKey: configItems.pgpPrivateKey,
@@ -267,8 +234,7 @@ async function _createTransport(sendConfiguration) {
transport.mailer = {
sendConfiguration,
throttleWait: bluebird.promisify(throttleWait),
- sendTransactionalMail: async (mail, template) => await _sendTransactionalMail(transport, mail),
- sendTransactionalMailBasedOnTemplate: async (mail, template) => await _sendTransactionalMailBasedOnTemplate(transport, mail, template),
+ sendTransactionalMail: async (mail) => await _sendTransactionalMail(transport, mail),
sendMassMail: async (mail, template) => await _sendMail(transport, mail)
};
@@ -286,3 +252,4 @@ class MailerError extends Error {
module.exports.getOrCreateMailer = getOrCreateMailer;
module.exports.invalidateMailer = invalidateMailer;
module.exports.MailerError = MailerError;
+module.exports.SendConfigurationError = SendConfigurationError;
\ No newline at end of file
diff --git a/server/lib/campaign-sender.js b/server/lib/message-sender.js
similarity index 59%
rename from server/lib/campaign-sender.js
rename to server/lib/message-sender.js
index 380b9bdc..f666288e 100644
--- a/server/lib/campaign-sender.js
+++ b/server/lib/message-sender.js
@@ -15,9 +15,9 @@ const links = require('../models/links');
const {CampaignSource, CampaignType} = require('../../shared/campaigns');
const {SubscriptionStatus} = require('../../shared/lists');
const tools = require('./tools');
+const htmlToText = require('html-to-text');
const request = require('request-promise');
const files = require('../models/files');
-const htmlToText = require('html-to-text');
const {getPublicUrl} = require('./urls');
const blacklist = require('../models/blacklist');
const libmime = require('libmime');
@@ -26,10 +26,11 @@ const { enforce } = require('./helpers');
const MessageType = {
REGULAR: 0,
TRIGGERED: 1,
- TEST: 2
+ TEST: 2,
+ SUBSCRIPTION: 3
};
-class CampaignSender {
+class MessageSender {
constructor() {
}
@@ -40,6 +41,8 @@ class CampaignSender {
- sendConfiguration, listId, attachments, html, text, subject
*/
async _init(settings) {
+ this.type = settings.type;
+
this.listsById = new Map(); // listId -> list
this.listsByCid = new Map(); // listCid -> list
this.listsFieldsGrouped = new Map(); // listId -> fieldsGrouped
@@ -47,11 +50,13 @@ class CampaignSender {
await knex.transaction(async tx => {
if (settings.campaignCid) {
this.campaign = await campaigns.rawGetByTx(tx, 'cid', settings.campaignCid);
+ this.isMassMail = true;
} else if (settings.campaignId) {
this.campaign = await campaigns.rawGetByTx(tx, 'id', settings.campaignId);
+ this.isMassMail = true;
- } else {
+ } else if (this.type === MessageType.TEST) {
// We are not within scope of a campaign (i.e. templates in MessageType.TEST message)
// This is to fake the campaign for getMessageLinks, which is called inside formatMessage
this.campaign = {
@@ -60,11 +65,15 @@ class CampaignSender {
from_email_override: null,
reply_to_override: null
};
+ this.isMassMail = true;
+
+ } else {
+ this.isMassMail = false;
}
if (settings.sendConfigurationId) {
this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), settings.sendConfigurationId, false, true);
- } else if (this.campaign.send_configuration) {
+ } else if (this.campaign && this.campaign.send_configuration) {
this.sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), this.campaign.send_configuration, false, true);
} else {
enforce(false);
@@ -79,7 +88,7 @@ class CampaignSender {
this.listsByCid.set(list.cid, list);
this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
- } else if (this.campaign.lists) {
+ } else if (this.campaign && this.campaign.lists) {
for (const listSpec of this.campaign.lists) {
const list = await lists.getByIdTx(tx, contextHelpers.getAdminContext(), listSpec.list);
this.listsById.set(list.id, list);
@@ -94,7 +103,7 @@ class CampaignSender {
if (settings.attachments) {
this.attachments = settings.attachments;
- } else if (this.campaign.id) {
+ } else if (this.campaign && this.campaign.id) {
const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', this.campaign.id);
this.attachments = [];
@@ -109,16 +118,21 @@ class CampaignSender {
this.attachments = [];
}
- if (settings.html !== undefined) {
+ if (settings.renderedHtml !== undefined) {
+ this.rendereHtml = settings.rendereHtml;
+ this.renderedText = settings.renderedText;
+
+ } else if (settings.html !== undefined) {
this.html = settings.html;
this.text = settings.text;
- } else if (this.campaign.source === CampaignSource.TEMPLATE) {
+
+ } else if (this.campaign && this.campaign.source === CampaignSource.TEMPLATE) {
this.template = await templates.getByIdTx(tx, contextHelpers.getAdminContext(), this.campaign.data.sourceTemplate, false);
}
if (settings.subject !== undefined) {
this.subject = settings.subject;
- } else if (this.campaign.subject !== undefined) {
+ } else if (this.campaign && this.campaign.subject !== undefined) {
this.subject = this.campaign.subject;
} else {
enforce(false);
@@ -142,7 +156,7 @@ class CampaignSender {
text = this.text;
renderTags = true;
- } else {
+ } else if (campaign) {
if (campaign.source === CampaignSource.URL) {
const form = tools.getMessageLinks(campaign, list, subscriptionGrouped);
for (const key in mergeTags) {
@@ -222,14 +236,16 @@ class CampaignSender {
}
async initByCampaignCid(campaignCid) {
- await this._init({campaignCid});
+ await this._init({type: MessageType.REGULAR, campaignCid});
}
async initByCampaignId(campaignId) {
- await this._init({campaignId});
+ await this._init({type: MessageType.REGULAR, campaignId});
}
async getMessage(listCid, subscriptionCid) {
+ enforce(this.type === MessageType.REGULAR);
+
const list = this.listsByCid.get(listCid);
const subscriptionGrouped = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, subscriptionCid);
const flds = this.listsFieldsGrouped.get(list.id);
@@ -242,93 +258,81 @@ class CampaignSender {
/*
subData is one of:
- - queuedMessage
+ - subscriptionId, listId, attachments
or
- email, listId
+ or
+ - to, subject
*/
async _sendMessage(subData) {
- let msgType;
- let subscriptionGrouped;
- let listId;
+ let msgType = this.type;
+ let to, email;
+ let envelope = false;
+ let sender = false;
+ let headers = {};
+ let listHeader = false;
+ let encryptionKeys = [];
+ let subject;
+ let message;
- if (subData.queuedMessage) {
- const queuedMessage = subData.queuedMessage;
- msgType = queuedMessage.type;
- listId = queuedMessage.list;
- subscriptionGrouped = await subscriptions.getById(contextHelpers.getAdminContext(), listId, queuedMessage.subscription);
+ let subscriptionGrouped, list; // May be undefined
+ const campaign = this.campaign; // May be undefined
- } else {
- enforce(subData.email);
- enforce(subData.listId);
+ if (subData.listId) {
+ let listId;
+ subscriptionGrouped;
- msgType = MessageType.REGULAR;
- listId = subData.listId;
- subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), listId, subData.email);
- }
+ if (subData.subscriptionId) {
+ listId = subData.listId;
+ subscriptionGrouped = await subscriptions.getById(contextHelpers.getAdminContext(), listId, subData.subscriptionId);
- const email = subscriptionGrouped.email;
-
- if (await blacklist.isBlacklisted(email)) {
- return;
- }
-
- const list = this.listsById.get(listId);
- const flds = this.listsFieldsGrouped.get(list.id);
- const campaign = this.campaign;
-
- const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
-
- const encryptionKeys = [];
- for (const fld of flds) {
- if (fld.type === 'gpg' && mergeTags[fld.key]) {
- encryptionKeys.push(mergeTags[fld.key].trim());
+ } else if (subData.email) {
+ listId = subData.listId;
+ subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), listId, subData.email);
}
- }
- const sendConfiguration = this.sendConfiguration;
+ list = this.listsById.get(listId);
+ email = subscriptionGrouped.email;
- const {html, text, attachments} = await this._getMessage(list, subscriptionGrouped, mergeTags, true);
+ const flds = this.listsFieldsGrouped.get(list.id);
+ const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
- const campaignAddress = [campaign.cid, list.cid, subscriptionGrouped.cid].join('.');
-
- let listUnsubscribe = null;
- if (!list.listunsubscribe_disabled) {
- listUnsubscribe = campaign.unsubscribe_url
- ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, campaign.unsubscribe_url)
- : getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscriptionGrouped.cid);
- }
-
- const mailer = await mailers.getOrCreateMailer(sendConfiguration.id);
-
- await mailer.throttleWait();
-
- const getOverridable = key => {
- if (sendConfiguration[key + '_overridable'] && this.campaign[key + '_override'] !== null) {
- return campaign[key + '_override'] || '';
- } else {
- return sendConfiguration[key] || '';
+ for (const fld of flds) {
+ if (fld.type === 'gpg' && mergeTags[fld.key]) {
+ encryptionKeys.push(mergeTags[fld.key].trim());
+ }
}
- };
- const mail = {
- from: {
- name: getOverridable('from_name'),
- address: getOverridable('from_email')
- },
- replyTo: getOverridable('reply_to'),
- xMailer: sendConfiguration.x_mailer ? sendConfiguration.x_mailer : false,
- to: {
+ message = await this._getMessage(list, subscriptionGrouped, mergeTags, true);
+
+ const campaignAddress = [campaign.cid, list.cid, subscriptionGrouped.cid].join('.');
+
+ let listUnsubscribe = null;
+ if (!list.listunsubscribe_disabled) {
+ listUnsubscribe = campaign.unsubscribe_url
+ ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, campaign.unsubscribe_url)
+ : getPublicUrl('/subscription/' + list.cid + '/unsubscribe/' + subscriptionGrouped.cid);
+ }
+
+ to = {
name: list.to_name === null ? undefined : tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false),
address: subscriptionGrouped.email
- },
- sender: this.useVerpSenderHeader ? campaignAddress + '@' + sendConfiguration.verp_hostname : false,
+ };
- envelope: this.useVerp ? {
- from: campaignAddress + '@' + sendConfiguration.verp_hostname,
- to: subscriptionGrouped.email
- } : false,
+ subject = tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, this.subject, false);
- headers: {
+ if (this.useVerp) {
+ envelope = {
+ from: campaignAddress + '@' + sendConfiguration.verp_hostname,
+ to: subscriptionGrouped.email
+ };
+ }
+
+ if (this.useVerpSenderHeader) {
+ sender = campaignAddress + '@' + sendConfiguration.verp_hostname;
+ }
+
+ headers = {
'x-fbl': campaignAddress,
// custom header for SparkPost
'x-msys-api': JSON.stringify({
@@ -348,175 +352,237 @@ class CampaignSender {
prepared: true,
value: libmime.encodeWords(list.name) + ' <' + list.cid + '.' + getPublicUrl() + '>'
}
- },
- list: {
- unsubscribe: listUnsubscribe
- },
- subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, this.subject, false),
- html,
- text,
+ };
- attachments,
+ listHeader = {
+ unsubscribe: listUnsubscribe
+ };
+
+ } else if (subData.to) {
+ to = subData.to;
+ email = to.address;
+ subject = this.subject;
+ encryptionKeys = subData.encryptionKeys;
+ message = await this._getMessage();
+ }
+
+ if (await blacklist.isBlacklisted(email)) {
+ return;
+ }
+
+ const sendConfiguration = this.sendConfiguration;
+ const mailer = await mailers.getOrCreateMailer(sendConfiguration.id);
+
+ await mailer.throttleWait();
+
+ const getOverridable = key => {
+ if (campaign && sendConfiguration[key + '_overridable'] && this.campaign[key + '_override'] !== null) {
+ return campaign[key + '_override'] || '';
+ } else {
+ return sendConfiguration[key] || '';
+ }
+ };
+
+ const mail = {
+ from: {
+ name: getOverridable('from_name'),
+ address: getOverridable('from_email')
+ },
+ replyTo: getOverridable('reply_to'),
+ xMailer: sendConfiguration.x_mailer ? sendConfiguration.x_mailer : false,
+ to,
+ sender,
+ envelope,
+ headers,
+ list: listHeader,
+ subject,
+ html: message.html,
+ text: message.text,
+ attachments: message.attachments || [],
encryptionKeys
};
- let status;
let response;
let responseId = null;
- try {
- const info = await mailer.sendMassMail(mail);
- status = SubscriptionStatus.SUBSCRIBED;
- log.verbose('CampaignSender', `response: ${info.response} messageId: ${info.messageId}`);
+ const info = this.isMassMail ? await mailer.sendMassMail(mail) : await mailer.sendTransactionalMail(mail);
- let match;
- if ((match = info.response.match(/^250 Message queued as ([0-9a-f]+)$/))) {
- /*
- ZoneMTA
- info.response: 250 Message queued as 1691ad7f7ae00080fd
- info.messageId:
- */
- response = info.response;
- responseId = match[1];
-
- } else if ((match = info.messageId.match(/^<([^>@]*)@.*amazonses\.com>$/))) {
- /*
- AWS SES
- info.response: 0102016ad2244c0a-955492f2-9194-4cd1-bef9-70a45906a5a7-000000
- info.messageId: <0102016ad2244c0a-955492f2-9194-4cd1-bef9-70a45906a5a7-000000@eu-west-1.amazonses.com>
- */
- response = info.response;
- responseId = match[1];
-
- } else if (info.response.match(/^250 OK$/) && (match = info.messageId.match(/^<([^>]*)>$/))) {
- /*
- Postal Mail Server
- info.response: 250 OK
- info.messageId: (postal messageId)
- */
- response = info.response;
- responseId = match[1];
-
- } else {
- /*
- Fallback - Mailtrain v1 behavior
- */
- response = info.response || info.messageId;
- responseId = response.split(/\s+/).pop();
- }
-
-
- if (msgType === MessageType.REGULAR || msgType === MessageType.TRIGGERED) {
- await knex('campaigns').where('id', campaign.id).increment('delivered');
- }
- } catch (err) {
- console.log(err);
+ log.verbose('MessageSender', `response: ${info.response} messageId: ${info.messageId}`);
+ let match;
+ if ((match = info.response.match(/^250 Message queued as ([0-9a-f]+)$/))) {
/*
- { Error: connect ECONNREFUSED 127.0.0.1:55871
- at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1097:14)
- cause:
- { Error: connect ECONNREFUSED 127.0.0.1:55871
- at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1097:14)
- stack:
- 'Error: connect ECONNREFUSED 127.0.0.1:55871\n at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1097:14)',
- errno: 'ECONNREFUSED',
- code: 'ECONNECTION',
- syscall: 'connect',
- address: '127.0.0.1',
- port: 55871,
- command: 'CONN' },
- isOperational: true,
- errno: 'ECONNREFUSED',
- code: 'ECONNECTION',
- syscall: 'connect',
- address: '127.0.0.1',
- port: 55871,
- command: 'CONN' }
-
+ ZoneMTA
+ info.response: 250 Message queued as 1691ad7f7ae00080fd
+ info.messageId:
*/
+ response = info.response;
+ responseId = match[1];
- status = SubscriptionStatus.BOUNCED;
- response = err.response || err.message;
- if (msgType === MessageType.REGULAR || msgType === MessageType.TRIGGERED) {
- await knex('campaigns').where('id', campaign.id).increment('delivered').increment('bounced');
- }
+ } else if ((match = info.messageId.match(/^<([^>@]*)@.*amazonses\.com>$/))) {
+ /*
+ AWS SES
+ info.response: 0102016ad2244c0a-955492f2-9194-4cd1-bef9-70a45906a5a7-000000
+ info.messageId: <0102016ad2244c0a-955492f2-9194-4cd1-bef9-70a45906a5a7-000000@eu-west-1.amazonses.com>
+ */
+ response = info.response;
+ responseId = match[1];
+
+ } else if (info.response.match(/^250 OK$/) && (match = info.messageId.match(/^<([^>]*)>$/))) {
+ /*
+ Postal Mail Server
+ info.response: 250 OK
+ info.messageId: (postal messageId)
+ */
+ response = info.response;
+ responseId = match[1];
+
+ } else {
+ /*
+ Fallback - Mailtrain v1 behavior
+ */
+ response = info.response || info.messageId;
+ responseId = response.split(/\s+/).pop();
+ }
+
+
+ if (msgType === MessageType.REGULAR || msgType === MessageType.TRIGGERED) {
+ await knex('campaigns').where('id', campaign.id).increment('delivered');
}
const now = new Date();
if (msgType === MessageType.REGULAR) {
+ enforce(list);
+ enforce(subscriptionGrouped);
+
await knex('campaign_messages').insert({
campaign: this.campaign.id,
list: list.id,
subscription: subscriptionGrouped.id,
send_configuration: sendConfiguration.id,
- status,
+ status: SubscriptionStatus.SUBSCRIBED,
response,
response_id: responseId,
updated: now
});
- } else if (msgType === MessageType.TRIGGERED || msgType === MessageType.TEST) {
- if (subData.queuedMessage.data.attachments) {
- for (const attachment of subData.queuedMessage.data.attachments) {
+ } else if (msgType === MessageType.TRIGGERED || msgType === MessageType.TEST || msgType === MessageType.SUBSCRIPTION) {
+ if (subData.attachments) {
+ for (const attachment of subData.attachments) {
try {
// We ignore any errors here because we already sent the message. Thus we have to mark it as completed to avoid sending it again.
await knex.transaction(async tx => {
await files.unlockTx(tx, 'campaign', 'attachment', attachment.id);
});
} catch (err) {
- log.error('CampaignSender', `Error when unlocking attachment ${attachment.id} for ${listId}:${subscriptionGrouped.email} (queuedId: ${subData.queuedMessage.id})`);
+ log.error('MessageSender', `Error when unlocking attachment ${attachment.id} for ${email} (queuedId: ${subData.queuedId})`);
log.verbose(err.stack);
}
}
}
await knex('queued')
- .where({id: subData.queuedMessage.id})
+ .where({id: subData.queuedId})
.del();
}
}
async sendRegularMessage(listId, email) {
+ enforce(this.type === MessageType.REGULAR);
+
await this._sendMessage({listId, email});
}
}
-CampaignSender.sendQueuedMessage = async (queuedMessage) => {
+async function sendQueuedMessage(queuedMessage) {
const msgData = queuedMessage.data;
- const cs = new CampaignSender();
+ const cs = new MessageSender();
await cs._init({
+ type: queuedMessage.type,
campaignId: msgData.campaignId,
- listId: queuedMessage.list,
+ listId: msgData.listId,
sendConfigurationId: queuedMessage.send_configuration,
attachments: msgData.attachments,
html: msgData.html,
text: msgData.text,
- subject: msgData.subject
+ subject: msgData.subject,
+ renderedHtml: msgData.renderedHtml,
+ renderedText: msgData.renderedText
});
- await cs._sendMessage({queuedMessage});
-};
+ await cs._sendMessage({
+ subscriptionId: msgData.subscriptionId,
+ listId: msgData.listId,
+ to: msgData.to,
+ attachments: msgData.attachments,
+ encryptionKeys: msgData.encryptionKeys,
+ queuedId: queuedMessage.id
+ });
+}
-CampaignSender.queueMessageTx = async (tx, sendConfigurationId, listId, subscriptionId, messageType, messageData) => {
- if (messageData.attachments) {
+async function queueCampaignMessageTx(tx, sendConfigurationId, listId, subscriptionId, messageType, messageData) {
+ enforce(messageType === MessageType.TRIGGERED || messageType === MessageType.TEST);
+
+ const msgData = {...messageData};
+
+ if (msgData.attachments) {
for (const attachment of messageData.attachments) {
await files.lockTx(tx,'campaign', 'attachment', attachment.id);
}
}
+ msgData.listId = listId;
+ msgData.subscriptionId = subscriptionId;
+
await tx('queued').insert({
send_configuration: sendConfigurationId,
- list: listId,
- subscription: subscriptionId,
type: messageType,
- data: JSON.stringify(messageData)
+ data: JSON.stringify(msgData)
});
-};
+}
-module.exports.CampaignSender = CampaignSender;
-module.exports.MessageType = MessageType;
\ No newline at end of file
+async function queueSubscriptionMessage(sendConfigurationId, to, subject, encryptionKeys, template) {
+ let html, text;
+
+ const htmlRenderer = await tools.getTemplate(template.html, template.locale);
+ if (htmlRenderer) {
+ html = htmlRenderer(template.data || {});
+
+ if (html) {
+ html = await tools.prepareHtml(html);
+ }
+ }
+
+ const textRenderer = await tools.getTemplate(template.text, template.locale);
+ if (textRenderer) {
+ text = textRenderer(template.data || {});
+ } else if (html) {
+ text = htmlToText.fromString(html, {
+ wordwrap: 130
+ });
+ }
+
+ const msgData = {
+ renderedHtml: html,
+ renderedText: text,
+ to,
+ subject,
+ encryptionKeys
+ };
+
+ await tx('queued').insert({
+ send_configuration: sendConfigurationId,
+ type: MessageType.SUBSCRIPTION,
+ data: JSON.stringify(msgData)
+ });
+}
+
+module.exports.MessageSender = MessageSender;
+module.exports.MessageType = MessageType;
+module.exports.sendQueuedMessage = sendQueuedMessage;
+module.exports.queueCampaignMessageTx = queueCampaignMessageTx;
+module.exports.queueSubscriptionMessage = queueSubscriptionMessage;
diff --git a/server/lib/senders.js b/server/lib/senders.js
index 7ea15a28..476603b5 100644
--- a/server/lib/senders.js
+++ b/server/lib/senders.js
@@ -15,6 +15,7 @@ function spawn(callback) {
log.verbose('Senders', 'Spawning master sender process');
knex('campaigns').where('status', CampaignStatus.SENDING).update({status: CampaignStatus.SCHEDULED})
+ .then(() => knex('campaigns').where('status', CampaignStatus.PAUSING).update({status: CampaignStatus.PAUSED}))
.then(() => {
senderProcess = fork(path.join(__dirname, '..', 'services', 'sender-master.js'), [], {
cwd: path.join(__dirname, '..'),
diff --git a/server/lib/subscription-mail-helpers.js b/server/lib/subscription-mail-helpers.js
index 838cb75c..9ffa161a 100644
--- a/server/lib/subscription-mail-helpers.js
+++ b/server/lib/subscription-mail-helpers.js
@@ -5,11 +5,10 @@ const fields = require('../models/fields');
const settings = require('../models/settings');
const {getTrustedUrl, getPublicUrl} = require('./urls');
const { tUI, tMark } = require('./translate');
-const util = require('util');
const contextHelpers = require('./context-helpers');
const {getFieldColumn} = require('../../shared/lists');
const forms = require('../models/forms');
-const mailers = require('./mailers');
+const messageSender = require('./message-sender');
module.exports = {
sendAlreadySubscribed,
@@ -138,20 +137,21 @@ async function _sendMail(list, email, template, locale, subjectKey, relativeUrls
try {
if (list.send_configuration) {
- const mailer = await mailers.getOrCreateMailer(list.send_configuration);
- await mailer.sendTransactionalMailBasedOnTemplate({
- to: {
+ await messageSender.queueSubscriptionMessage(
+ list.send_configuration,
+ {
name: getDisplayName(flds, subscription),
address: email
},
- subject: tUI(subjectKey, locale, { list: list.name }),
- encryptionKeys
- }, {
- html,
- text,
- locale,
- data
- });
+ tUI(subjectKey, locale, { list: list.name }),
+ encryptionKeys,
+ {
+ html,
+ text,
+ locale,
+ data
+ }
+ );
} else {
log.warn('Subscription', `Not sending email for list id:${list.id} because not send configuration is set.`);
}
diff --git a/server/lib/tools.js b/server/lib/tools.js
index 8fb0c67b..3e6f0888 100644
--- a/server/lib/tools.js
+++ b/server/lib/tools.js
@@ -42,9 +42,7 @@ async function getLocalizedFile(basePath, fileName, language) {
}
async function getTemplate(template, locale) {
- if (!template) {
- return false;
- }
+ enforce(template);
const key = getLangCodeFromExpressLocale(locale) + ':' + ((typeof template === 'object') ? hasher.hash(template) : template);
@@ -148,7 +146,7 @@ function validateEmailGetMessage(result, address, language) {
}
function formatMessage(campaign, list, subscription, mergeTags, message, isHTML) {
- const links = getMessageLinks(campaign, list, subscription);
+ const links = campaign && list && subscription ? getMessageLinks(campaign, list, subscription) : {};
return formatTemplate(message, links, mergeTags, isHTML);
}
@@ -192,10 +190,6 @@ function formatTemplate(template, links, mergeTags, isHTML) {
}
async function prepareHtml(html) {
- if (!(html || '').toString().trim()) {
- return false;
- }
-
const { window } = new JSDOM(html);
const head = window.document.querySelector('head');
diff --git a/server/models/campaigns.js b/server/models/campaigns.js
index 98a02592..7a06731d 100644
--- a/server/models/campaigns.js
+++ b/server/models/campaigns.js
@@ -17,11 +17,11 @@ const {SubscriptionStatus} = require('../../shared/lists');
const subscriptions = require('./subscriptions');
const segments = require('./segments');
const senders = require('../lib/senders');
-const {LinkId} = require('./links');
+const links = require('./links');
const feedcheck = require('../lib/feedcheck');
const contextHelpers = require('../lib/context-helpers');
const {convertFileURLs} = require('../lib/campaign-content');
-const {CampaignSender, MessageType} = require('../lib/campaign-sender');
+const messageSender = require('../lib/message-sender');
const lists = require('./lists');
const {EntityActivityType, CampaignActivityType} = require('../../shared/activity-log');
@@ -298,7 +298,7 @@ async function listOpensDTAjax(context, campaignId, params) {
return this.from('campaign_links')
.where('campaign_links.campaign', campaignId)
.where('campaign_links.list', cpgList.list)
- .where('campaign_links.link', LinkId.OPEN)
+ .where('campaign_links.link', links.LinkId.OPEN)
.as('related_campaign_links');
},
'related_campaign_links.subscription', subsTable + '.id')
@@ -705,16 +705,12 @@ async function getMessageByCid(messageCid, withVerpHostname = false) { // withVe
}
async function getMessageByResponseId(responseId) {
- return await knex.transaction(async tx => {
- const message = await tx('campaign_messages')
- .where('campaign_messages.response_id', responseId)
- .select([
- 'campaign_messages.id', 'campaign_messages.campaign', 'campaign_messages.list', 'campaign_messages.subscription', 'campaign_messages.status'
- ])
- .first();
-
- return message;
- });
+ return await knex('campaign_messages')
+ .where('campaign_messages.response_id', responseId)
+ .select([
+ 'campaign_messages.id', 'campaign_messages.campaign', 'campaign_messages.list', 'campaign_messages.subscription', 'campaign_messages.status'
+ ])
+ .first();
}
const statusFieldMapping = {
@@ -747,7 +743,6 @@ async function _changeStatusByMessageTx(tx, context, message, subscriptionStatus
}
async function changeStatusByCampaignCidAndSubscriptionIdTx(tx, context, campaignCid, listId, subscriptionId, subscriptionStatus) {
- const campaign = await tx('campaigns').where('cid', campaignCid);
const message = await tx('campaign_messages')
.innerJoin('campaigns', 'campaign_messages.campaign', 'campaigns.id')
.where('campaigns.cid', campaignCid)
@@ -856,7 +851,7 @@ async function getSubscribersQueryGeneratorTx(tx, campaignId) {
}
}
-async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, scheduled = null) {
+async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, startAt) {
await knex.transaction(async tx => {
const entity = await getByIdTx(tx, context, campaignId, false);
@@ -866,10 +861,18 @@ async function _changeStatus(context, campaignId, permittedCurrentStates, newSta
throw new interoperableErrors.InvalidStateError(invalidStateMessage);
}
- await tx('campaigns').where('id', campaignId).update({
+ const updateData = {
status: newState,
- scheduled
- });
+ };
+
+ if (startAt !== undefined) {
+ updateData.scheduled = startAt;
+ if (!startAt || startAt.valueOf() < Date.now()) {
+ updateData.start_at = new Date();
+ }
+ }
+
+ await tx('campaigns').where('id', campaignId).update(updateData);
await activityLog.logEntityActivity('campaign', CampaignActivityType.STATUS_CHANGE, campaignId, {status: newState});
});
@@ -929,8 +932,8 @@ async function getStatisticsOpened(context, id) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
- const devices = await tx('campaign_links').where('campaign', id).where('link', LinkId.OPEN).groupBy('device_type').select('device_type AS key').count('* as count');
- const countries = await tx('campaign_links').where('campaign', id).where('link', LinkId.OPEN).groupBy('country').select('country AS key').count('* as count');
+ const devices = await tx('campaign_links').where('campaign', id).where('link', links.LinkId.OPEN).groupBy('device_type').select('device_type AS key').count('* as count');
+ const countries = await tx('campaign_links').where('campaign', id).where('link', links.LinkId.OPEN).groupBy('country').select('country AS key').count('* as count');
return {
devices,
@@ -959,7 +962,7 @@ async function testSend(context, data) {
await knex.transaction(async tx => {
const processSubscriber = async (sendConfigurationId, listId, subscriptionId, messageData) => {
- await CampaignSender.queueMessageTx(tx, sendConfigurationId, listId, subscriptionId, MessageType.TEST, messageData);
+ await messageSender.queueCampaignMessageTx(tx, sendConfigurationId, listId, subscriptionId, messageSender.MessageType.TEST, messageData);
await activityLog.logEntityActivity('campaign', CampaignActivityType.TEST_SEND, campaignId, {list: listId, subscription: subscriptionId});
};
diff --git a/server/models/confirmations.js b/server/models/confirmations.js
index b3312bcb..f14dfd38 100644
--- a/server/models/confirmations.js
+++ b/server/models/confirmations.js
@@ -21,7 +21,7 @@ async function addConfirmation(listId, action, ip, data) {
*/
async function takeConfirmation(cid) {
return await knex.transaction(async tx => {
- const entry = await tx('confirmations').select(['cid', 'list', 'action', 'ip', 'data']).where('cid', cid).first();
+ const entry = await tx('confirmations').select(['cid', 'list', 'action', 'ip', 'data']).where('cid', cid).forUpdate().first();
if (!entry) {
return false;
diff --git a/server/models/subscriptions.js b/server/models/subscriptions.js
index 3c02b0c5..4a298dd8 100644
--- a/server/models/subscriptions.js
+++ b/server/models/subscriptions.js
@@ -504,7 +504,7 @@ async function serverValidate(context, listId, data) {
async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta, isCreate) {
enforce(entity.email, 'Email must be set');
- const existingWithKeyQuery = tx(getSubscriptionTableName(listId)).where('hash_email', hashEmail(entity.email));
+ const existingWithKeyQuery = tx(getSubscriptionTableName(listId)).where('hash_email', hashEmail(entity.email)).forUpdate();
if (!isCreate) {
existingWithKeyQuery.whereNot('id', entity.id);
diff --git a/server/models/templates.js b/server/models/templates.js
index c04b2cca..cd7c22ca 100644
--- a/server/models/templates.js
+++ b/server/models/templates.js
@@ -153,7 +153,7 @@ async function remove(context, id) {
const MAX_EMAIL_COUNT = 100;
async function sendAsTransactionalEmail(context, templateId, sendConfigurationId, emails, subject, mergeTags) {
- // TODO - Update this to use CampaignSender.queueMessageTx (with renderedHtml and renderedText)
+ // TODO - Update this to use MessageSender.queueMessageTx (with renderedHtml and renderedText)
if (emails.length > MAX_EMAIL_COUNT) {
throw new Error(`Cannot send more than ${MAX_EMAIL_COUNT} emails at once`);
diff --git a/server/models/users.js b/server/models/users.js
index 038fc045..b35ceaae 100644
--- a/server/models/users.js
+++ b/server/models/users.js
@@ -12,6 +12,8 @@ const crypto = require('crypto');
const settings = require('./settings');
const {getTrustedUrl} = require('../lib/urls');
const { tUI } = require('../lib/translate');
+const messageSender = require('../lib/message-sender');
+const {getSystemSendConfigurationId} = require('../../shared/send-configurations');
const bluebird = require('bluebird');
@@ -19,8 +21,6 @@ const bcrypt = require('bcrypt-nodejs');
const bcryptHash = bluebird.promisify(bcrypt.hash.bind(bcrypt));
const bcryptCompare = bluebird.promisify(bcrypt.compare.bind(bcrypt));
-const mailers = require('../lib/mailers');
-
const passport = require('../lib/passport');
const namespaceHelpers = require('../lib/namespace-helpers');
@@ -297,26 +297,31 @@ async function resetAccessToken(userId) {
async function sendPasswordReset(locale, usernameOrEmail) {
enforce(passport.isAuthMethodLocal, 'Local user management is required');
+ const resetToken = crypto.randomBytes(16).toString('base64').replace(/[^a-z0-9]/gi, '');
+ let user;
+
await knex.transaction(async tx => {
- const user = await tx('users').where('username', usernameOrEmail).orWhere('email', usernameOrEmail).select(['id', 'username', 'email', 'name']).first();
+ user = await tx('users').where('username', usernameOrEmail).orWhere('email', usernameOrEmail).select(['id', 'username', 'email', 'name']).forUpdate().first();
if (user) {
- const resetToken = crypto.randomBytes(16).toString('base64').replace(/[^a-z0-9]/gi, '');
-
await tx('users').where('id', user.id).update({
reset_token: resetToken,
reset_expire: new Date(Date.now() + 60 * 60 * 1000)
});
+ }
+ // We intentionally silently ignore the situation when user is not found. This is not to reveal if a user exists in the system.
- const { adminEmail } = await settings.get(contextHelpers.getAdminContext(), ['adminEmail']);
+ });
- const mailer = await mailers.getOrCreateMailer();
- await mailer.sendTransactionalMailBasedOnTemplate({
- to: {
- address: user.email
- },
- subject: tUI('mailerPasswordChangeRequest', locale)
- }, {
+ if (user) {
+ await messageSender.queueSubscriptionMessage(
+ getSystemSendConfigurationId(),
+ {
+ address: user.email
+ },
+ tUI('mailerPasswordChangeRequest', locale),
+ null,
+ {
html: 'users/password-reset-html.hbs',
text: 'users/password-reset-text.hbs',
locale,
@@ -326,10 +331,9 @@ async function sendPasswordReset(locale, usernameOrEmail) {
name: user.name,
confirmUrl: getTrustedUrl(`login/reset/${encodeURIComponent(user.username)}/${encodeURIComponent(resetToken)}`)
}
- });
- }
- // We intentionally silently ignore the situation when user is not found. This is not to reveal if a user exists in the system.
- });
+ }
+ );
+ }
}
async function isPasswordResetTokenValid(username, resetToken) {
diff --git a/server/routes/archive.js b/server/routes/archive.js
index 566fa146..10b602d8 100644
--- a/server/routes/archive.js
+++ b/server/routes/archive.js
@@ -1,11 +1,11 @@
'use strict';
const router = require('../lib/router-async').create();
-const {CampaignSender} = require('../lib/campaign-sender');
+const {MessageSender} = require('../lib/message-sender');
router.get('/:campaign/:list/:subscription', (req, res, next) => {
- const cs = new CampaignSender();
+ const cs = new MessageSender();
cs.initByCampaignCid(req.params.campaign)
.then(() => cs.getMessage(req.params.list, req.params.subscription))
.then(result => {
diff --git a/server/services/sender-master.js b/server/services/sender-master.js
index 05f4aaf5..36fc5b8b 100644
--- a/server/services/sender-master.js
+++ b/server/services/sender-master.js
@@ -10,7 +10,7 @@ const campaigns = require('../models/campaigns');
const builtinZoneMta = require('../lib/builtin-zone-mta');
const {CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
-const {MessageType} = require('../lib/campaign-sender')
+const {MessageType} = require('../lib/message-sender');
require('../lib/fork');
class Notifications {
@@ -55,19 +55,111 @@ const idleWorkers = [];
let campaignSchedulerRunning = false;
let queuedSchedulerRunning = false;
-const campaignsCheckPeriod = 30 * 1000;
+const checkPeriod = 30 * 1000;
const retrieveBatchSize = 1000;
const workerBatchSize = 10;
+const sendConfigurationIdByCampaignId = new Map(); // campaignId -> sendConfigurationId
+const sendConfigurationStatuses = new Map(); // sendConfigurationId -> {retryCount, postponeTill}
+
const sendConfigurationMessageQueue = new Map(); // sendConfigurationId -> [{queuedMessage}]
const campaignMessageQueue = new Map(); // campaignId -> [{listId, email}]
-const workAssignment = new Map(); // workerId -> { campaignId, messages: [{listId, email} } / { sendConfigurationId, messages: [{queuedMessage}] }
+const workAssignment = new Map(); // workerId -> { type: WorkAssignmentType.CAMPAIGN, campaignId, messages: [{listId, email} } / { type: WorkAssignmentType.QUEUED, sendConfigurationId, messages: [{queuedMessage}] }
+
+const WorkAssignmentType = {
+ CAMPAIGN: 0,
+ QUEUED: 1
+};
+
+const retryBackoff = [10, 20, 30, 30, 60, 60, 120, 120, 300]; // in seconds
+
+function getSendConfigurationStatus(sendConfigurationId) {
+ let status = sendConfigurationStatuses.get(sendConfigurationId);
+ if (!status) {
+ status = {
+ retryCount: 0,
+ postponeTill: 0
+ };
+
+ sendConfigurationStatuses.set(sendConfigurationId, status);
+ }
+
+ return status;
+}
+
+function setSendConfigurationRetryCount(sendConfigurationStatus, newRetryCount) {
+ sendConfigurationStatus.retryCount = newRetryCount;
+
+ let next = 0;
+ if (newRetryCount > 0) {
+ let backoff;
+ if (newRetryCount > retryBackoff.length) {
+ backoff = retryBackoff[retryBackoff.length - 1];
+ } else {
+ backoff = retryBackoff[newRetryCount - 1];
+ }
+
+ next = Date.now() + backoff * 1000;
+ setTimeout(scheduleCheck, backoff * 1000);
+ }
+
+ sendConfigurationStatus.postponeTill = next;
+}
+
+function isSendConfigurationPostponed(sendConfigurationId) {
+ const now = Date.now();
+ const sendConfigurationStatus = getSendConfigurationStatus(sendConfigurationId);
+ return sendConfigurationStatus.postponeTill > now;
+}
+
+function getPostponedSendConfigurationIds() {
+ const result = [];
+ const now = Date.now();
+
+ for (const entry of sendConfigurationStatuses.entries()) {
+ if (entry[1].postponeTill > now) {
+ result.push(entry[0]);
+ }
+ }
+
+ return result;
+}
-function messagesProcessed(workerId) {
+function getExpirationThresholds() {
+ const now = Date.now();
+
+ return {
+ [MessageType.TRIGGERED]: {
+ threshold: now - config.queue.retention.triggered * 1000,
+ title: 'triggered campaign'
+ },
+ [MessageType.TEST]: {
+ threshold: now - config.queue.retention.test * 1000,
+ title: 'test campaign'
+ },
+ [MessageType.SUBSCRIPTION]: {
+ threshold: now - config.queue.retention.subscription * 1000,
+ title: 'subscription and password-related'
+ }
+ };
+}
+
+
+function messagesProcessed(workerId, withErrors) {
const wa = workAssignment.get(workerId);
+ const sendConfigurationStatus = getSendConfigurationStatus(wa.sendConfigurationId);
+ if (withErrors) {
+ if (sendConfigurationStatus.retryCount === wa.sendConfigurationRetryCount) { // This is to avoid multiple increments when more workers simultaneously fail to send messages ot the same send configuration
+ setSendConfigurationRetryCount(sendConfigurationStatus, sendConfigurationStatus.retryCount + 1);
+ }
+ } else {
+ setSendConfigurationRetryCount(sendConfigurationStatus, 0);
+ }
+
+
workAssignment.delete(workerId);
idleWorkers.push(workerId);
@@ -75,8 +167,6 @@ function messagesProcessed(workerId) {
}
async function workersLoop() {
- const reservedWorkersForTestCount = workersCount > 1 ? 1 : 0;
-
async function getAvailableWorker() {
while (idleWorkers.length === 0) {
await notifier.waitFor('workerFinished');
@@ -85,64 +175,42 @@ async function workersLoop() {
return idleWorkers.shift();
}
- function assignCampaignTaskToWorker(workerId, task) {
- const campaignId = task.campaignId;
- const queue = task.queue;
-
- const messages = queue.splice(0, workerBatchSize);
- workAssignment.set(workerId, {campaignId, messages});
-
- if (queue.length === 0) {
- notifier.notify(`campaignMessageQueueEmpty:${campaignId}`);
- }
-
- sendToWorker(workerId, 'process-campaign-messages', {
- campaignId,
- messages
- });
- }
-
- function assignSendConfigurationTaskToWorker(workerId, task) {
- const sendConfigurationId = task.sendConfigurationId;
- const queue = task.queue;
-
- const messages = queue.splice(0, workerBatchSize);
- workAssignment.set(workerId, {sendConfigurationId, messages});
-
- if (queue.length === 0) {
- notifier.notify(`sendConfigurationMessageQueueEmpty:${sendConfigurationId}`);
- }
-
- sendToWorker(workerId, 'process-queued-messages', {
- sendConfigurationId,
- messages
- });
- }
-
function selectNextTask() {
const allocationMap = new Map();
const allocation = [];
- function initAllocation(attrName, queues, assignWorkerHandler) {
+ function initAllocation(waType, attrName, queues, workerMsg, getSendConfigurationId, getQueueEmptyEvent) {
for (const id of queues.keys()) {
+ const sendConfigurationId = getSendConfigurationId(id);
const key = attrName + ':' + id;
const queue = queues.get(id);
+ const postponed = isSendConfigurationPostponed(sendConfigurationId);
+
const task = {
- [attrName]: id,
+ type: waType,
+ id,
existingWorkers: 0,
- isEmpty: queue.length === 0,
+ isValid: queue.length > 0 && !postponed,
queue,
- assignWorkerHandler
+ workerMsg,
+ attrName,
+ getQueueEmptyEvent,
+ sendConfigurationId
};
allocationMap.set(key, task);
allocation.push(task);
+
+ if (postponed && queue.length > 0) {
+ queue.splice(0);
+ notifier.notify(task.getQueueEmptyEvent(task));
+ }
}
for (const wa of workAssignment.values()) {
- if (wa[attrName]) {
+ if (wa.type === waType) {
const key = attrName + ':' + wa[attrName];
const task = allocationMap.get(key);
task.existingWorkers += 1;
@@ -150,14 +218,29 @@ async function workersLoop() {
}
}
- initAllocation('sendConfigurationId', sendConfigurationMessageQueue, assignSendConfigurationTaskToWorker);
- initAllocation('campaignId', campaignMessageQueue, assignCampaignTaskToWorker);
+ initAllocation(
+ WorkAssignmentType.QUEUED,
+ 'sendConfigurationId',
+ sendConfigurationMessageQueue,
+ 'process-queued-messages',
+ id => id,
+ task => `sendConfigurationMessageQueueEmpty:${task.id}`
+ );
+
+ initAllocation(
+ WorkAssignmentType.CAMPAIGN,
+ 'campaignId',
+ campaignMessageQueue,
+ 'process-campaign-messages',
+ id => sendConfigurationIdByCampaignId.get(id),
+ task => `campaignMessageQueueEmpty:${task.id}`
+ );
let minTask = null;
let minExistingWorkers;
for (const task of allocation) {
- if (!task.isEmpty && (minTask === null || minExistingWorkers > task.existingWorkers)) {
+ if (task.isValid && (minTask === null || minExistingWorkers > task.existingWorkers)) {
minTask = task;
minExistingWorkers = task.existingWorkers;
}
@@ -172,8 +255,31 @@ async function workersLoop() {
if (task) {
const workerId = await getAvailableWorker();
- task.assignWorkerHandler(workerId, task);
-
+
+ const attrName = task.attrName;
+ const sendConfigurationId = task.sendConfigurationId;
+ const sendConfigurationStatus = getSendConfigurationStatus(sendConfigurationId);
+ const sendConfigurationRetryCount = sendConfigurationStatus.retryCount;
+
+ const queue = task.queue;
+
+ const messages = queue.splice(0, workerBatchSize);
+ workAssignment.set(workerId, {
+ type: task.type,
+ [attrName]: task.id,
+ sendConfigurationId,
+ sendConfigurationRetryCount,
+ messages
+ });
+
+ if (queue.length === 0) {
+ notifier.notify(task.getQueueEmptyEvent(task));
+ }
+
+ sendToWorker(workerId, task.workerMsg, {
+ [attrName]: task.id,
+ messages
+ });
} else {
await notifier.waitFor('workAvailable');
}
@@ -184,14 +290,18 @@ async function workersLoop() {
async function processCampaign(campaignId) {
const msgQueue = campaignMessageQueue.get(campaignId);
- async function finish(newStatus) {
+ async function finish(clearMsgQueue, newStatus) {
+ if (clearMsgQueue) {
+ msgQueue.splice(0);
+ }
+
const isCompleted = () => {
if (msgQueue.length > 0) return false;
let workerRunning = false;
for (const wa of workAssignment.values()) {
- if (wa.campaignId === campaignId) {
+ if (wa.type === WorkAssignmentType.CAMPAIGN && wa.campaignId === campaignId) {
workerRunning = true;
}
}
@@ -213,10 +323,20 @@ async function processCampaign(campaignId) {
while (true) {
const cpg = await knex('campaigns').where('id', campaignId).first();
+ const expirationThreshold = Date.now() - config.queue.retention.campaign * 1000;
+ if (cpg.start_at.valueOf() < expirationThreshold) {
+ return await finish(true, CampaignStatus.FINISHED);
+ }
+
if (cpg.status === CampaignStatus.PAUSING) {
- msgQueue.splice(0);
- await finish(CampaignStatus.PAUSED);
- return;
+ return await finish(true, CampaignStatus.PAUSED);
+ }
+
+ sendConfigurationIdByCampaignId.set(cpg.id, cpg.send_configuration);
+
+ if (isSendConfigurationPostponed(cpg.send_configuration)) {
+ // postpone campaign if its send configuration is problematic
+ return await finish(true, CampaignStatus.SCHEDULED);
}
let qryGen;
@@ -227,7 +347,7 @@ async function processCampaign(campaignId) {
if (qryGen) {
let messagesInProcessing = [...msgQueue];
for (const wa of workAssignment.values()) {
- if (wa.campaignId === campaignId) {
+ if (wa.type === WorkAssignmentType.CAMPAIGN && wa.campaignId === campaignId) {
messagesInProcessing = messagesInProcessing.concat(wa.messages);
}
}
@@ -239,8 +359,7 @@ async function processCampaign(campaignId) {
const subs = await qry;
if (subs.length === 0) {
- await finish(CampaignStatus.FINISHED);
- return;
+ return await finish(false, CampaignStatus.FINISHED);
}
for (const sub of subs) {
@@ -257,8 +376,7 @@ async function processCampaign(campaignId) {
}
} else {
- await finish(CampaignStatus.FINISHED);
- return;
+ return await finish(false, CampaignStatus.FINISHED);
}
}
} catch (err) {
@@ -276,15 +394,29 @@ async function scheduleCampaigns() {
campaignSchedulerRunning = true;
try {
+ // finish old campaigns
+ const nowDate = new Date();
+ const now = nowDate.valueOf();
+
+ const expirationThreshold = new Date(now - config.queue.retention.campaign * 1000);
+ const expiredCampaigns = await knex('campaigns')
+ .whereIn('campaigns.type', [CampaignType.REGULAR, CampaignType.RSS_ENTRY])
+ .whereIn('campaigns.status', [CampaignStatus.SCHEDULED, CampaignStatus.PAUSED])
+ .where('campaigns.start_at', '<', expirationThreshold)
+ .update({status: CampaignStatus.FINISHED});
+
while (true) {
let campaignId = 0;
+ const postponedSendConfigurationIds = getPostponedSendConfigurationIds();
await knex.transaction(async tx => {
const scheduledCampaign = await tx('campaigns')
.whereIn('campaigns.type', [CampaignType.REGULAR, CampaignType.RSS_ENTRY])
+ .whereNotIn('campaigns.send_configuration', postponedSendConfigurationIds)
.where('campaigns.status', CampaignStatus.SCHEDULED)
- .where(qry => qry.whereNull('campaigns.scheduled').orWhere('campaigns.scheduled', '<=', new Date()))
+ .where('campaigns.start_at', '<=', nowDate)
.select(['id'])
+ .forUpdate()
.first();
if (scheduledCampaign) {
@@ -322,7 +454,7 @@ async function processQueuedBySendConfiguration(sendConfigurationId) {
let workerRunning = false;
for (const wa of workAssignment.values()) {
- if (wa.sendConfigurationId === sendConfigurationId) {
+ if (wa.type === WorkAssignmentType.QUEUED && wa.sendConfigurationId === sendConfigurationId) {
workerRunning = true;
}
}
@@ -330,19 +462,40 @@ async function processQueuedBySendConfiguration(sendConfigurationId) {
return !workerRunning;
};
+ async function finish(clearMsgQueue, deleteMsgQueue) {
+ if (clearMsgQueue) {
+ msgQueue.splice(0);
+ }
+
+ while (!isCompleted()) {
+ await notifier.waitFor('workerFinished');
+ }
+
+ if (deleteMsgQueue) {
+ sendConfigurationMessageQueue.delete(sendConfigurationId);
+ }
+ }
+
+
try {
while (true) {
+ if (isSendConfigurationPostponed(sendConfigurationId)) {
+ return finish(true, true);
+ }
+
let messagesInProcessing = [...msgQueue];
for (const wa of workAssignment.values()) {
- if (wa.sendConfigurationId === sendConfigurationId) {
+ if (wa.type === WorkAssignmentType.QUEUED && wa.sendConfigurationId === sendConfigurationId) {
messagesInProcessing = messagesInProcessing.concat(wa.messages);
}
}
+ const messageIdsInProcessing = messagesInProcessing.map(x => x.queuedMessage.id);
+
const rows = await knex('queued')
- .orderByRaw(`FIELD(type, ${MessageType.TRIGGERED}, ${MessageType.TEST}) DESC, id ASC`) // This orders MessageType.TEST messages before MessageType.TRIGGERED ones
+ .orderByRaw(`FIELD(type, ${MessageType.TRIGGERED}, ${MessageType.TEST}, ${MessageType.SUBSCRIPTION}) DESC, id ASC`) // This orders messages in the following order MessageType.SUBSCRIPTION, MessageType.TEST and MessageType.TRIGGERED
.where('send_configuration', sendConfigurationId)
- .whereNotIn('id', messagesInProcessing.map(x => x.queuedMessage.id))
+ .whereNotIn('id', messageIdsInProcessing)
.limit(retrieveBatchSize);
if (rows.length === 0) {
@@ -351,20 +504,43 @@ async function processQueuedBySendConfiguration(sendConfigurationId) {
return;
} else {
- while (!isCompleted()) {
- await notifier.waitFor('workerFinished');
- }
+ finish(false, false);
// At this point, there might be new messages in the queued that could belong to us. Thus we have to try again instead for returning.
continue;
}
}
+ const expirationThresholds = getExpirationThresholds();
+ const expirationCounters = {};
+ for (const type of Object.keys(expirationThresholds)) {
+ expirationCounters[type] = 0;
+ }
+
for (const row of rows) {
- row.data = JSON.parse(row.data);
- msgQueue.push({
- queuedMessage: row
- });
+ for (const type of Object.keys(expirationThresholds)) {
+ if (row.type === type) {
+ const expirationThreshold = expirationThresholds[type];
+
+ if (row.created < expirationThreshold.threshold) {
+ expirationCounters[type] += 1;
+ await knex('queued').where('id', row.id).del();
+
+ } else {
+ row.data = JSON.parse(row.data);
+ msgQueue.push({
+ queuedMessage: row
+ });
+ }
+ }
+ }
+ }
+
+ for (const type of Object.keys(expirationThresholds)) {
+ const expirationThreshold = expirationThresholds[type];
+ if (expirationCounters[type] > 0) {
+ log.warn('Senders', `Discarded ${expirationCounters[type]} expired ${expirationThreshold.title} message(s).`);
+ }
}
notifier.notify('workAvailable');
@@ -387,22 +563,37 @@ async function scheduleQueued() {
queuedSchedulerRunning = true;
try {
- while (true) {
- const sendConfigurationsInProcessing = [...sendConfigurationMessageQueue.keys()];
+ const sendConfigurationsIdsInProcessing = [...sendConfigurationMessageQueue.keys()];
+ const postponedSendConfigurationIds = getPostponedSendConfigurationIds();
- const rows = await knex('queued')
- .whereNotIn('send_configuration', sendConfigurationsInProcessing)
- .groupBy('send_configuration')
- .select(['send_configuration']);
+ // prune old messages
+ const expirationThresholds = getExpirationThresholds();
+ for (const type of Object.keys(expirationThresholds)) {
+ const expirationThreshold = expirationThresholds[type];
- for (const row of rows) {
- const sendConfigurationId = row.send_configuration;
- sendConfigurationMessageQueue.set(sendConfigurationId, []);
+ const expiredCount = await knex('queued')
+ .whereNotIn('send_configuration', sendConfigurationsIdsInProcessing)
+ .where('type', type)
+ .where('created', '<', expirationThreshold.threshold)
+ .del();
- // noinspection JSIgnoredPromiseFromCall
- processQueuedBySendConfiguration(sendConfigurationId);
+ if (expiredCount) {
+ log.warn('Senders', `Discarded ${expiredCount} expired ${expirationThreshold.title} message(s).`);
}
}
+
+ const rows = await knex('queued')
+ .whereNotIn('send_configuration', [...sendConfigurationsIdsInProcessing, ...postponedSendConfigurationIds])
+ .groupBy('send_configuration')
+ .select(['send_configuration']);
+
+ for (const row of rows) {
+ const sendConfigurationId = row.send_configuration;
+ sendConfigurationMessageQueue.set(sendConfigurationId, []);
+
+ // noinspection JSIgnoredPromiseFromCall
+ processQueuedBySendConfiguration(sendConfigurationId);
+ }
} catch (err) {
log.error('Senders', `Scheduling queued messages failed with error: ${err.message}`);
log.verbose(err.stack);
@@ -431,7 +622,7 @@ async function spawnWorker(workerId) {
return resolve();
} else if (msg.type === 'messages-processed') {
- messagesProcessed(workerId);
+ messagesProcessed(workerId, msg.data.withErrors);
}
}
@@ -457,16 +648,22 @@ function sendToWorker(workerId, msgType, data) {
}
-function periodicCampaignsCheck() {
+function scheduleCheck() {
// noinspection JSIgnoredPromiseFromCall
scheduleCampaigns();
// noinspection JSIgnoredPromiseFromCall
scheduleQueued();
-
- setTimeout(periodicCampaignsCheck, campaignsCheckPeriod);
}
+function periodicCheck() {
+ // noinspection JSIgnoredPromiseFromCall
+ scheduleCheck();
+
+ setTimeout(periodicCheck, checkPeriod);
+}
+
+
async function init() {
const spawnWorkerFutures = [];
let workerId;
@@ -482,10 +679,18 @@ async function init() {
if (type === 'schedule-check') {
// noinspection JSIgnoredPromiseFromCall
- scheduleCampaigns();
- scheduleQueued();
+ scheduleCheck();
} else if (type === 'reload-config') {
+ const sendConfigurationStatus = getSendConfigurationStatus(msg.data.sendConfigurationId);
+ if (sendConfigurationStatus.retryCount > 0) {
+ const sendConfigurationStatus = getSendConfigurationStatus(msg.data.sendConfigurationId)
+ setSendConfigurationRetryCount(sendConfigurationStatus, 0);
+
+ // noinspection JSIgnoredPromiseFromCall
+ scheduleCheck();
+ }
+
for (const workerId of workerProcesses.keys()) {
sendToWorker(workerId, 'reload-config', msg.data);
}
@@ -501,7 +706,7 @@ async function init() {
type: 'master-sender-started'
});
- periodicCampaignsCheck();
+ periodicCheck();
setImmediate(workersLoop);
}
diff --git a/server/services/sender-worker.js b/server/services/sender-worker.js
index a9f4c3d8..ff830adb 100644
--- a/server/services/sender-worker.js
+++ b/server/services/sender-worker.js
@@ -3,7 +3,7 @@
const config = require('config');
const log = require('../lib/log');
const mailers = require('../lib/mailers');
-const {CampaignSender} = require('../lib/campaign-sender');
+const messageSender = require('../lib/message-sender');
require('../lib/fork');
const workerId = Number.parseInt(process.argv[2]);
@@ -17,23 +17,33 @@ async function processCampaignMessages(campaignId, messages) {
running = true;
- const cs = new CampaignSender();
+ const cs = new MessageSender();
await cs.initByCampaignId(campaignId);
+ let withErrors = false;
+
for (const msgData of messages) {
try {
await cs.sendRegularMessage(msgData.listId, msgData.email);
log.verbose('Senders', 'Message sent and status updated for %s:%s', msgData.listId, msgData.email);
} catch (err) {
- log.error('Senders', `Sending message to ${msgData.listId}:${msgData.email} failed with error: ${err.message}`);
- log.verbose(err.stack);
+
+ if (err instanceof mailers.SendConfigurationError) {
+ log.error('Senders', `Sending message to ${msgData.listId}:${msgData.email} failed with error: ${err.message}. Will retry the message if within retention interval.`);
+ withErrors = true;
+ break;
+
+ } else {
+ log.error('Senders', `Sending message to ${msgData.listId}:${msgData.email} failed with error: ${err.message}. Dropping the message.`);
+ log.verbose(err.stack);
+ }
}
}
running = false;
- sendToMaster('messages-processed');
+ sendToMaster('messages-processed', { withErrors });
}
async function processQueuedMessages(sendConfigurationId, messages) {
@@ -44,26 +54,34 @@ async function processQueuedMessages(sendConfigurationId, messages) {
running = true;
+ let withErrors = false;
+
for (const msgData of messages) {
const queuedMessage = msgData.queuedMessage;
try {
- await CampaignSender.sendQueuedMessage(queuedMessage);
-
+ await messageSender.sendQueuedMessage(queuedMessage);
log.verbose('Senders', 'Message sent and status updated for %s:%s', queuedMessage.list, queuedMessage.subscription);
} catch (err) {
- log.error('Senders', `Sending message to ${queuedMessage.list}:${queuedMessage.subscription} failed with error: ${err.message}`);
- log.verbose(err.stack);
+ if (err instanceof mailers.SendConfigurationError) {
+ log.error('Senders', `Sending message to ${queuedMessage.list}:${queuedMessage.subscription} failed with error: ${err.message}. Will retry the message if within retention interval.`);
+ withErrors = true;
+ break;
+ } else {
+ log.error('Senders', `Sending message to ${queuedMessage.list}:${queuedMessage.subscription} failed with error: ${err.message}. Dropping the message.`);
+ log.verbose(err.stack);
+ }
}
}
running = false;
- sendToMaster('messages-processed');
+ sendToMaster('messages-processed', { withErrors });
}
-function sendToMaster(msgType) {
+function sendToMaster(msgType, data) {
process.send({
- type: msgType
+ type: msgType,
+ data
});
}
diff --git a/server/services/triggers.js b/server/services/triggers.js
index 3689f425..0eb58ee3 100644
--- a/server/services/triggers.js
+++ b/server/services/triggers.js
@@ -10,7 +10,7 @@ const { Entity, Event } = require('../../shared/triggers');
const { SubscriptionStatus } = require('../../shared/lists');
const links = require('../models/links');
const contextHelpers = require('../lib/context-helpers');
-const {MessageType, CampaignSender} = require('../lib/campaign-sender');
+const messageSender = require('../lib/message-sender');
const triggerCheckPeriod = 30 * 1000;
const triggerFirePeriod = 120 * 1000;
@@ -152,8 +152,8 @@ async function run() {
subscription: subscriber.id
});
- await CampaignSender.queueMessageTx(tx,
- campaign.send_configuration, cpgList.list, subscriber.id, MessageType.TRIGGERED,
+ await messageSender.queueCampaignMessageTx(tx,
+ campaign.send_configuration, cpgList.list, subscriber.id, messageSender.MessageType.TRIGGERED,
{
campaignId: campaign.id,
triggerId: trigger.id
diff --git a/server/setup/knex/migrations/20190615000000_generalization_of_queued_and_file_locking.js b/server/setup/knex/migrations/20190615000000_generalization_of_queued_and_file_locking.js
index 99b2c6d1..e479e653 100644
--- a/server/setup/knex/migrations/20190615000000_generalization_of_queued_and_file_locking.js
+++ b/server/setup/knex/migrations/20190615000000_generalization_of_queued_and_file_locking.js
@@ -15,7 +15,7 @@ const entityTypesWithFiles = {
exports.up = (knex, Promise) => (async() => {
await knex.schema.table('queued', table => {
table.integer('send_configuration').unsigned().notNullable();
- table.integer('type').unsigned().notNullable(); // The values come from campaign-sender.js:MessageType
+ table.integer('type').unsigned().notNullable(); // The values come from message-sender.js:MessageType
table.text('data', 'longtext');
});
diff --git a/server/setup/knex/migrations/20190629000000_add_start_at_to_campaigns.js b/server/setup/knex/migrations/20190629000000_add_start_at_to_campaigns.js
new file mode 100644
index 00000000..593885b9
--- /dev/null
+++ b/server/setup/knex/migrations/20190629000000_add_start_at_to_campaigns.js
@@ -0,0 +1,26 @@
+const { CampaignType, CampaignStatus } = require('../../../../shared/campaigns');
+
+exports.up = (knex, Promise) => (async() => {
+ await knex.schema.table('campaigns', table => {
+ table.timestamp('start_at').nullable().defaultTo(null);
+ });
+
+ await knex('campaigns')
+ .whereIn('type', [CampaignType.REGULAR, CampaignType.RSS_ENTRY])
+ .whereIn('status', [CampaignStatus.SCHEDULED, CampaignStatus.SENDING, CampaignStatus.PAUSING, CampaignStatus.PAUSED])
+ .whereNotNull('scheduled')
+ .update({
+ start_at: knex.raw('scheduled')
+ });
+
+ await knex('campaigns')
+ .whereIn('type', [CampaignType.REGULAR, CampaignType.RSS_ENTRY])
+ .whereIn('status', [CampaignStatus.SCHEDULED, CampaignStatus.SENDING, CampaignStatus.PAUSING, CampaignStatus.PAUSED])
+ .whereNull('scheduled')
+ .update({
+ start_at: new Date()
+ });
+})();
+
+exports.down = (knex, Promise) => (async() => {
+})();
diff --git a/server/setup/knex/migrations/20190629170000_generalization_of_queued.js b/server/setup/knex/migrations/20190629170000_generalization_of_queued.js
new file mode 100644
index 00000000..ef8dee76
--- /dev/null
+++ b/server/setup/knex/migrations/20190629170000_generalization_of_queued.js
@@ -0,0 +1,25 @@
+exports.up = (knex, Promise) => (async() => {
+ const queued = await knex('queued');
+
+ for (const queuedEntry of queued) {
+ const data = JSON.parse(queuedEntry.data);
+
+ data.listId = queuedEntry.list;
+ data.subscriptionId = queuedEntry.subscription;
+
+ knex('queued')
+ .where('id', queuedEntry.id)
+ .update({
+ data: JSON.stringify(data)
+ });
+ }
+
+ await knex.schema.table('queued', table => {
+ table.dropColumn('list');
+ table.dropColumn('subscription');
+ });
+
+})();
+
+exports.down = (knex, Promise) => (async() => {
+})();
From 450b930cc5c43b7bf33fd578494aa31b40c2973c Mon Sep 17 00:00:00 2001
From: Tomas Bures
Date: Sun, 30 Jun 2019 10:47:09 +0200
Subject: [PATCH 06/29] Work in progress on refactoring all mail sending to use
the message sender an sender workers. Some fixes related to subscriptions and
password reset.
---
client/src/login/Reset.js | 2 +-
server/lib/message-sender.js | 18 +++++---
server/lib/subscription-mail-helpers.js | 40 ++++--------------
server/lib/tools.js | 5 +--
server/models/users.js | 7 ++--
server/services/feedcheck.js | 1 -
server/services/sender-master.js | 55 ++++++++++++++++---------
server/services/sender-worker.js | 43 ++++++++++++++-----
8 files changed, 95 insertions(+), 76 deletions(-)
diff --git a/client/src/login/Reset.js b/client/src/login/Reset.js
index f9fe5b6c..0743ca0d 100644
--- a/client/src/login/Reset.js
+++ b/client/src/login/Reset.js
@@ -49,7 +49,7 @@ export default class Account extends Component {
}
submitFormValuesMutator(data) {
- return filterData(data, ['password']);
+ return filterData(data, ['username', 'password', 'resetToken']);
}
@withAsyncErrorHandler
diff --git a/server/lib/message-sender.js b/server/lib/message-sender.js
index f666288e..c19448a2 100644
--- a/server/lib/message-sender.js
+++ b/server/lib/message-sender.js
@@ -22,6 +22,7 @@ const {getPublicUrl} = require('./urls');
const blacklist = require('../models/blacklist');
const libmime = require('libmime');
const { enforce } = require('./helpers');
+const senders = require('./senders');
const MessageType = {
REGULAR: 0,
@@ -95,9 +96,6 @@ class MessageSender {
this.listsByCid.set(list.cid, list);
this.listsFieldsGrouped.set(list.id, await fields.listGroupedTx(tx, list.id));
}
-
- } else {
- enforce(false);
}
if (settings.attachments) {
@@ -119,7 +117,7 @@ class MessageSender {
}
if (settings.renderedHtml !== undefined) {
- this.rendereHtml = settings.rendereHtml;
+ this.renderedHtml = settings.renderedHtml;
this.renderedText = settings.renderedText;
} else if (settings.html !== undefined) {
@@ -146,6 +144,7 @@ class MessageSender {
let renderTags = false;
const campaign = this.campaign;
+
if (this.renderedHtml !== undefined) {
html = this.renderedHtml;
text = this.renderedText;
@@ -497,6 +496,12 @@ class MessageSender {
}
}
+async function dropQueuedMessage(queuedMessage) {
+ await knex('queued')
+ .where({id: queuedMessage.id})
+ .del();
+}
+
async function sendQueuedMessage(queuedMessage) {
const msgData = queuedMessage.data;
@@ -574,11 +579,13 @@ async function queueSubscriptionMessage(sendConfigurationId, to, subject, encryp
encryptionKeys
};
- await tx('queued').insert({
+ await knex('queued').insert({
send_configuration: sendConfigurationId,
type: MessageType.SUBSCRIPTION,
data: JSON.stringify(msgData)
});
+
+ senders.scheduleCheck();
}
module.exports.MessageSender = MessageSender;
@@ -586,3 +593,4 @@ module.exports.MessageType = MessageType;
module.exports.sendQueuedMessage = sendQueuedMessage;
module.exports.queueCampaignMessageTx = queueCampaignMessageTx;
module.exports.queueSubscriptionMessage = queueSubscriptionMessage;
+module.exports.dropQueuedMessage = dropQueuedMessage;
\ No newline at end of file
diff --git a/server/lib/subscription-mail-helpers.js b/server/lib/subscription-mail-helpers.js
index 9ffa161a..1c057243 100644
--- a/server/lib/subscription-mail-helpers.js
+++ b/server/lib/subscription-mail-helpers.js
@@ -9,6 +9,7 @@ const contextHelpers = require('./context-helpers');
const {getFieldColumn} = require('../../shared/lists');
const forms = require('../models/forms');
const messageSender = require('./message-sender');
+const tools = require('./tools');
module.exports = {
sendAlreadySubscribed,
@@ -64,37 +65,12 @@ async function sendUnsubscriptionConfirmed(locale, list, email, subscription) {
await _sendMail(list, email, 'unsubscription_confirmed', locale, tMark('listUnsubscriptionConfirmed'), relativeUrls, subscription);
}
-function getDisplayName(flds, subscription) {
- let firstName, lastName, name;
-
- for (const fld of flds) {
- if (fld.key === 'FIRST_NAME') {
- firstName = subscription[fld.column];
- }
-
- if (fld.key === 'LAST_NAME') {
- lastName = subscription[fld.column];
- }
-
- if (fld.key === 'NAME') {
- name = subscription[fld.column];
- }
- }
-
- if (name) {
- return name;
- } else if (firstName && lastName) {
- return firstName + ' ' + lastName;
- } else if (lastName) {
- return lastName;
- } else if (firstName) {
- return firstName;
- } else {
- return '';
- }
-}
-
async function _sendMail(list, email, template, locale, subjectKey, relativeUrls, subscription) {
+ subscription = {
+ ...subscription,
+ email
+ };
+
const flds = await fields.list(contextHelpers.getAdminContext(), list.id);
const encryptionKeys = [];
@@ -136,11 +112,13 @@ async function _sendMail(list, email, template, locale, subjectKey, relativeUrls
}
try {
+ const mergeTags = fields.getMergeTags(flds, subscription);
+
if (list.send_configuration) {
await messageSender.queueSubscriptionMessage(
list.send_configuration,
{
- name: getDisplayName(flds, subscription),
+ name: list.to_name === null ? undefined : tools.formatTemplate(list.to_name, {}, mergeTags, false),
address: email
},
tUI(subjectKey, locale, { list: list.name }),
diff --git a/server/lib/tools.js b/server/lib/tools.js
index 3e6f0888..66c64647 100644
--- a/server/lib/tools.js
+++ b/server/lib/tools.js
@@ -1,12 +1,9 @@
'use strict';
-const util = require('util');
const isemail = require('isemail');
const path = require('path');
const {getPublicUrl} = require('./urls');
-
-const bluebird = require('bluebird');
-
+const {enforce} = require('./helpers');
const hasher = require('node-object-hash')();
const mjml2html = require('mjml');
diff --git a/server/models/users.js b/server/models/users.js
index b35ceaae..5ffea2f8 100644
--- a/server/models/users.js
+++ b/server/models/users.js
@@ -125,8 +125,6 @@ async function listDTAjax(context, params) {
async function _validateAndPreprocess(tx, entity, isCreate, isOwnAccount) {
enforce(await tools.validateEmail(entity.email) === 0, 'Invalid email');
- await namespaceHelpers.validateEntity(tx, entity);
-
const otherUserWithSameEmailQuery = tx('users').where('email', entity.email);
if (entity.id) {
otherUserWithSameEmailQuery.andWhereNot('id', entity.id);
@@ -138,6 +136,9 @@ async function _validateAndPreprocess(tx, entity, isCreate, isOwnAccount) {
if (!isOwnAccount) {
+ await namespaceHelpers.validateEntity(tx, entity);
+ enforce(entity.role in config.roles.global, 'Unknown role');
+
const otherUserWithSameUsernameQuery = tx('users').where('username', entity.username);
if (!isCreate) {
otherUserWithSameUsernameQuery.andWhereNot('id', entity.id);
@@ -148,8 +149,6 @@ async function _validateAndPreprocess(tx, entity, isCreate, isOwnAccount) {
}
}
- enforce(entity.role in config.roles.global, 'Unknown role');
-
enforce(!isCreate || entity.password.length > 0, 'Password not set');
if (entity.password) {
diff --git a/server/services/feedcheck.js b/server/services/feedcheck.js
index ed23ee5e..e42d6e49 100644
--- a/server/services/feedcheck.js
+++ b/server/services/feedcheck.js
@@ -5,7 +5,6 @@ const log = require('../lib/log');
const knex = require('../lib/knex');
const feedparser = require('feedparser-promised');
const { CampaignType, CampaignStatus, CampaignSource } = require('../../shared/campaigns');
-const util = require('util');
const campaigns = require('../models/campaigns');
const contextHelpers = require('../lib/context-helpers');
require('../lib/fork');
diff --git a/server/services/sender-master.js b/server/services/sender-master.js
index 36fc5b8b..49215a9c 100644
--- a/server/services/sender-master.js
+++ b/server/services/sender-master.js
@@ -175,6 +175,10 @@ async function workersLoop() {
return idleWorkers.shift();
}
+ function cancelWorker(workerId) {
+ idleWorkers.push(workerId);
+ }
+
function selectNextTask() {
const allocationMap = new Map();
const allocation = [];
@@ -251,11 +255,10 @@ async function workersLoop() {
while (true) {
+ const workerId = await getAvailableWorker();
const task = selectNextTask();
if (task) {
- const workerId = await getAvailableWorker();
-
const attrName = task.attrName;
const sendConfigurationId = task.sendConfigurationId;
const sendConfigurationStatus = getSendConfigurationStatus(sendConfigurationId);
@@ -280,7 +283,9 @@ async function workersLoop() {
[attrName]: task.id,
messages
});
+
} else {
+ cancelWorker(workerId);
await notifier.waitFor('workAvailable');
}
}
@@ -394,7 +399,7 @@ async function scheduleCampaigns() {
campaignSchedulerRunning = true;
try {
- // finish old campaigns
+ // Finish old campaigns
const nowDate = new Date();
const now = nowDate.valueOf();
@@ -405,6 +410,22 @@ async function scheduleCampaigns() {
.where('campaigns.start_at', '<', expirationThreshold)
.update({status: CampaignStatus.FINISHED});
+ // Empty message queues for PAUSING campaigns. A pausing campaign typically waits for campaignMessageQueueEmpty before it can check for PAUSING
+ // We speed this up by discarding messages in the message queue of the campaign.
+ const pausingCampaigns = await knex('campaigns')
+ .whereIn('campaigns.type', [CampaignType.REGULAR, CampaignType.RSS_ENTRY])
+ .where('campaigns.status', CampaignStatus.PAUSING)
+ .select(['id'])
+ .forUpdate();
+
+ for (const cpg of pausingCampaigns) {
+ const campaignId = cpg.id;
+ const queue = campaignMessageQueue.get(campaignId);
+ queue.splice(0);
+ notifier.notify(`campaignMessageQueueEmpty:${campaignId}`);
+ }
+
+
while (true) {
let campaignId = 0;
const postponedSendConfigurationIds = getPostponedSendConfigurationIds();
@@ -513,30 +534,26 @@ async function processQueuedBySendConfiguration(sendConfigurationId) {
const expirationThresholds = getExpirationThresholds();
const expirationCounters = {};
- for (const type of Object.keys(expirationThresholds)) {
+ for (const type in expirationThresholds) {
expirationCounters[type] = 0;
}
for (const row of rows) {
- for (const type of Object.keys(expirationThresholds)) {
- if (row.type === type) {
- const expirationThreshold = expirationThresholds[type];
+ const expirationThreshold = expirationThresholds[row.type];
- if (row.created < expirationThreshold.threshold) {
- expirationCounters[type] += 1;
- await knex('queued').where('id', row.id).del();
+ if (row.created < expirationThreshold.threshold) {
+ expirationCounters[row.type] += 1;
+ await knex('queued').where('id', row.id).del();
- } else {
- row.data = JSON.parse(row.data);
- msgQueue.push({
- queuedMessage: row
- });
- }
- }
+ } else {
+ row.data = JSON.parse(row.data);
+ msgQueue.push({
+ queuedMessage: row
+ });
}
}
- for (const type of Object.keys(expirationThresholds)) {
+ for (const type in expirationThresholds) {
const expirationThreshold = expirationThresholds[type];
if (expirationCounters[type] > 0) {
log.warn('Senders', `Discarded ${expirationCounters[type]} expired ${expirationThreshold.title} message(s).`);
@@ -568,7 +585,7 @@ async function scheduleQueued() {
// prune old messages
const expirationThresholds = getExpirationThresholds();
- for (const type of Object.keys(expirationThresholds)) {
+ for (const type in expirationThresholds) {
const expirationThreshold = expirationThresholds[type];
const expiredCount = await knex('queued')
diff --git a/server/services/sender-worker.js b/server/services/sender-worker.js
index ff830adb..447068ec 100644
--- a/server/services/sender-worker.js
+++ b/server/services/sender-worker.js
@@ -17,25 +17,25 @@ async function processCampaignMessages(campaignId, messages) {
running = true;
- const cs = new MessageSender();
+ const cs = new messageSender.MessageSender();
await cs.initByCampaignId(campaignId);
let withErrors = false;
- for (const msgData of messages) {
+ for (const msg of messages) {
try {
- await cs.sendRegularMessage(msgData.listId, msgData.email);
+ await cs.sendRegularMessage(msg.listId, msg.email);
- log.verbose('Senders', 'Message sent and status updated for %s:%s', msgData.listId, msgData.email);
+ log.verbose('Senders', 'Message sent and status updated for %s:%s', msg.listId, msg.email);
} catch (err) {
if (err instanceof mailers.SendConfigurationError) {
- log.error('Senders', `Sending message to ${msgData.listId}:${msgData.email} failed with error: ${err.message}. Will retry the message if within retention interval.`);
+ log.error('Senders', `Sending message to ${msg.listId}:${msg.email} failed with error: ${err.message}. Will retry the message if within retention interval.`);
withErrors = true;
break;
} else {
- log.error('Senders', `Sending message to ${msgData.listId}:${msgData.email} failed with error: ${err.message}. Dropping the message.`);
+ log.error('Senders', `Sending message to ${msg.listId}:${msg.email} failed with error: ${err.message}.`);
log.verbose(err.stack);
}
}
@@ -56,19 +56,40 @@ async function processQueuedMessages(sendConfigurationId, messages) {
let withErrors = false;
- for (const msgData of messages) {
- const queuedMessage = msgData.queuedMessage;
+ for (const msg of messages) {
+ const queuedMessage = msg.queuedMessage;
+
+ const msgData = queuedMessage.data;
+ let target = '';
+ if (msgData.listId && msgData.subscriptionId) {
+ target = `${msgData.listId}:${msgData.subscriptionId}`;
+ } else if (msgData.to) {
+ if (msgData.to.name && msgData.to.address) {
+ target = `${msgData.to.name} <${msgData.to.address}>`;
+ } else if (msgData.to.address) {
+ target = msgData.to.address;
+ } else {
+ target = msgData.to.toString();
+ }
+ }
+
try {
await messageSender.sendQueuedMessage(queuedMessage);
- log.verbose('Senders', 'Message sent and status updated for %s:%s', queuedMessage.list, queuedMessage.subscription);
+ log.verbose('Senders', `Message sent and status updated for ${target}`);
} catch (err) {
if (err instanceof mailers.SendConfigurationError) {
- log.error('Senders', `Sending message to ${queuedMessage.list}:${queuedMessage.subscription} failed with error: ${err.message}. Will retry the message if within retention interval.`);
+ log.error('Senders', `Sending message to ${target} failed with error: ${err.message}. Will retry the message if within retention interval.`);
withErrors = true;
break;
} else {
- log.error('Senders', `Sending message to ${queuedMessage.list}:${queuedMessage.subscription} failed with error: ${err.message}. Dropping the message.`);
+ log.error('Senders', `Sending message to ${target} failed with error: ${err.message}. Dropping the message.`);
log.verbose(err.stack);
+
+ try {
+ await messageSender.dropQueuedMessage(queuedMessage);
+ } catch (err) {
+ log.error(err.stack);
+ }
}
}
}
From 00e328a9142e361696f66eba3c6ffbef1b6fc13f Mon Sep 17 00:00:00 2001
From: Tomas Bures
Date: Wed, 3 Jul 2019 11:58:58 +0200
Subject: [PATCH 07/29] Work in progress on introducing tag language. Not
tested yet.
---
client/src/campaigns/CUD.js | 31 +-
client/src/campaigns/Content.js | 30 +-
client/src/lib/form.js | 5 +-
client/src/lib/tree.js | 16 +-
client/src/templates/CUD.js | 33 +-
client/src/templates/List.js | 11 +-
client/src/templates/helpers.js | 58 +-
client/src/templates/mosaico/CUD.js | 33 +-
client/src/templates/mosaico/List.js | 9 +-
client/src/templates/mosaico/helpers.js | 8 +-
server/config/default.yaml | 5 +
server/lib/client-helpers.js | 1 +
server/models/campaigns.js | 6 +
server/models/mosaico-templates.js | 20 +-
server/models/templates.js | 9 +-
server/routes/rest/mosaico-templates.js | 5 +
.../migrations/20170506102634_v1_to_v2.js | 3 +-
...20190629170000_generalization_of_queued.js | 2 +-
.../migrations/20190630210000_tag_language.js | 41 +
shared/mosaico-templates.js | 3726 +++++++++--------
shared/templates.js | 11 +-
21 files changed, 2154 insertions(+), 1909 deletions(-)
create mode 100644 server/setup/knex/migrations/20190630210000_tag_language.js
diff --git a/client/src/campaigns/CUD.js b/client/src/campaigns/CUD.js
index c37a195f..a51139e5 100644
--- a/client/src/campaigns/CUD.js
+++ b/client/src/campaigns/CUD.js
@@ -25,7 +25,7 @@ import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import {NamespaceSelect, validateNamespace} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
-import {getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers';
+import {getTagLanguages, getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import campaignsStyles from "./styles.scss";
@@ -50,6 +50,8 @@ export default class CUD extends Component {
const t = props.t;
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN);
+ this.tagLanguages = getTagLanguages(props.t);
+
this.mailerTypes = getMailerTypes(props.t);
const { campaignTypeLabels } = getCampaignLabels(t);
@@ -85,6 +87,11 @@ export default class CUD extends Component {
this.customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName});
}
+ this.customTemplateTagLanguageOptions = [];
+ for (const key of mailtrainConfig.tagLanguages) {
+ this.customTemplateTagLanguageOptions.push({key, label: this.tagLanguages[key].name});
+ }
+
this.state = {
sendConfiguration: null
};
@@ -120,6 +127,14 @@ export default class CUD extends Component {
}
}
+ if (key === undefined || key === 'data_sourceCustom_tag_language') {
+ if (newValue) {
+ const isEdit = !!this.props.entity;
+ const type = mutStateData.getIn(['data_sourceCustom_tag_language', 'value']);
+ this.templateTypes[type].afterTagLanguageChange(mutStateData, isEdit);
+ }
+ }
+
if (key === undefined || (match = key.match(/^(lists_[0-9]+_)list$/))) {
const prefix = match[1];
mutStateData.setIn([prefix + 'segment', 'value'], null);
@@ -202,6 +217,7 @@ export default class CUD extends Component {
data.data.sourceCustom = {
type: data.data_sourceCustom_type,
+ tag_language: data.data_sourceCustom_tag_language,
data: data.data_sourceCustom_data,
html: data.data_sourceCustom_html,
text: data.data_sourceCustom_text
@@ -257,7 +273,7 @@ export default class CUD extends Component {
if (this.props.entity.status === CampaignStatus.SENDING) {
this.disableForm();
}
-
+
} else {
const data = {};
for (const overridable of campaignOverridables) {
@@ -301,6 +317,7 @@ export default class CUD extends Component {
// This is for CampaignSource.CUSTOM
data_sourceCustom_type: mailtrainConfig.editors[0],
+ data_sourceCustom_tag_language: mailtrainConfig.tagLanguages[0],
data_sourceCustom_data: {},
data_sourceCustom_html: '',
data_sourceCustom_text: '',
@@ -362,6 +379,10 @@ export default class CUD extends Component {
state.setIn(['data_sourceCustom_type', 'error'], t('typeMustBeSelected'));
}
+ if (!state.getIn(['data_sourceCustom_tag_language', 'value'])) {
+ state.setIn(['data_sourceCustom_tag_language', 'error'], t('Tag language must be selected'));
+ }
+
if (customTemplateTypeKey) {
this.templateTypes[customTemplateTypeKey].validate(state);
}
@@ -654,8 +675,8 @@ export default class CUD extends Component {
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('type'), render: data => this.templateTypes[data].typeName },
- { data: 4, title: t('created'), render: data => moment(data).fromNow() },
- { data: 5, title: t('namespace') },
+ { data: 5, title: t('created'), render: data => moment(data).fromNow() },
+ { data: 6, title: t('namespace') },
];
let help = null;
@@ -690,6 +711,8 @@ export default class CUD extends Component {
templateEdit =
+
+
{customTemplateTypeForm}
;
diff --git a/client/src/campaigns/Content.js b/client/src/campaigns/Content.js
index 0a2da44c..d7f91576 100644
--- a/client/src/campaigns/Content.js
+++ b/client/src/campaigns/Content.js
@@ -7,6 +7,7 @@ import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
import {
Button,
ButtonRow,
+ Dropdown,
filterData,
Form,
FormSendMethod,
@@ -16,7 +17,7 @@ import {
} from '../lib/form';
import {withErrorHandling} from '../lib/error-handling';
import mailtrainConfig from 'mailtrainConfig';
-import {getEditForm, getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers';
+import {getEditForm, getTagLanguages, getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
@@ -39,12 +40,18 @@ export default class CustomContent extends Component {
const t = props.t;
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN);
+ this.tagLanguages = getTagLanguages(props.t);
this.customTemplateTypeOptions = [];
for (const key of mailtrainConfig.editors) {
this.customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName});
}
+ this.customTemplateTagLanguageOptions = [];
+ for (const key of mailtrainConfig.tagLanguages) {
+ this.customTemplateTagLanguageOptions.push({key, label: this.tagLanguages[key].name});
+ }
+
this.state = {
showMergeTagReference: false,
elementInFullscreen: false,
@@ -56,6 +63,9 @@ export default class CustomContent extends Component {
this.initForm({
getPreSubmitUpdater: ::this.getPreSubmitFormValuesUpdater,
+ onChangeBeforeValidation: {
+ data_sourceCustom_tag_language: ::this.onTagLanguageChanged
+ }
});
this.sendModalGetDataHandler = ::this.sendModalGetData;
@@ -71,9 +81,16 @@ export default class CustomContent extends Component {
setPanelInFullScreen: PropTypes.func
}
+ onTagLanguageChanged(mutStateData, key, oldTagLanguage, tagLanguage) {
+ if (tagLanguage) {
+ const type = mutStateData.getIn(['data_sourceCustom_tag_language', 'value']);
+ this.tagLanguages[type].afterTagLanguageChange(mutStateData, true);
+ }
+ }
getFormValuesMutator(data) {
data.data_sourceCustom_type = data.data.sourceCustom.type;
+ data.data_sourceCustom_tag_language = data.data.sourceCustom.tag_language;
data.data_sourceCustom_data = data.data.sourceCustom.data;
data.data_sourceCustom_html = data.data.sourceCustom.html;
data.data_sourceCustom_text = data.data.sourceCustom.text;
@@ -86,6 +103,7 @@ export default class CustomContent extends Component {
data.data.sourceCustom = {
type: data.data_sourceCustom_type,
+ tag_language: data.data_sourceCustom_tag_language,
data: data.data_sourceCustom_data,
html: data.data_sourceCustom_html,
text: data.data_sourceCustom_text
@@ -112,6 +130,12 @@ export default class CustomContent extends Component {
localValidateFormValues(state) {
const t = this.props.t;
+ if (!state.getIn(['data_sourceCustom_tag_language', 'value'])) {
+ state.setIn(['data_sourceCustom_tag_language', 'error'], t('Tag language must be selected'));
+ } else {
+ state.setIn(['data_sourceCustom_tag_language', 'error'], null);
+ }
+
const customTemplateTypeKey = state.getIn(['data_sourceCustom_type', 'value']);
if (customTemplateTypeKey) {
@@ -229,8 +253,6 @@ export default class CustomContent extends Component {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
- // FIXME - data_sourceCustom_type is initialized only after first render
-
return (
+
+
{customTemplateTypeKey && getTypeForm(this, customTemplateTypeKey, true)}
{customTemplateTypeKey && getEditForm(this, customTemplateTypeKey, 'data_sourceCustom_')}
diff --git a/client/src/lib/form.js b/client/src/lib/form.js
index 73c9baf0..c510c1db 100644
--- a/client/src/lib/form.js
+++ b/client/src/lib/form.js
@@ -712,7 +712,8 @@ class Dropdown extends Component {
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
options: PropTypes.array,
className: PropTypes.string,
- format: PropTypes.string
+ format: PropTypes.string,
+ disabled: PropTypes.bool
}
render() {
@@ -740,7 +741,7 @@ class Dropdown extends Component {
const className = owner.addFormValidationClass('form-control ' + (props.className || '') , id);
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
-