Added CSV export of subscribers
Fixed some bugs in subscriptions Updated some packages to avoid warnings about vulnerabilities Completed RSS feed campaigns
This commit is contained in:
parent
8683f8c91e
commit
bf69e633c4
47 changed files with 5255 additions and 9651 deletions
20
Gruntfile.js
20
Gruntfile.js
|
@ -6,31 +6,13 @@ module.exports = function (grunt) {
|
|||
grunt.initConfig({
|
||||
eslint: {
|
||||
all: ['lib/**/*.js', 'test/**/*.js', 'config/**/*.js', 'services/**/*.js', 'Gruntfile.js', 'app.js', 'index.js', 'routes/editorapi.js']
|
||||
},
|
||||
|
||||
nodeunit: {
|
||||
all: ['test/nodeunit/**/*-test.js']
|
||||
},
|
||||
|
||||
jsxgettext: {
|
||||
test: {
|
||||
files: [{
|
||||
src: ['views/**/*.hbs', 'lib/**/*.js', 'routes/**/*.js', 'services/**/*.js', 'app.js', 'index.js', '!ignored'],
|
||||
output: 'mailtrain.pot',
|
||||
'output-dir': './languages/'
|
||||
}],
|
||||
options: {
|
||||
keyword: ['translate', '_']
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load the plugin(s)
|
||||
grunt.loadNpmTasks('grunt-eslint');
|
||||
grunt.loadNpmTasks('grunt-contrib-nodeunit');
|
||||
grunt.task.loadTasks('tasks');
|
||||
|
||||
// Tasks
|
||||
grunt.registerTask('default', ['eslint', 'nodeunit', 'jsxgettext']);
|
||||
grunt.registerTask('default', ['eslint']);
|
||||
};
|
||||
|
|
|
@ -21,6 +21,7 @@ const api = require('./routes/api');
|
|||
|
||||
// These are routes for the new React-based client
|
||||
const reports = require('./routes/reports');
|
||||
const subscriptions = require('./routes/subscriptions');
|
||||
const subscription = require('./routes/subscription');
|
||||
const sandboxedMosaico = require('./routes/sandboxed-mosaico');
|
||||
const sandboxedCKEditor = require('./routes/sandboxed-ckeditor');
|
||||
|
@ -234,6 +235,7 @@ function createApp(appType) {
|
|||
useWith404Fallback('/reports', reports);
|
||||
}
|
||||
|
||||
useWith404Fallback('/subscriptions', subscriptions);
|
||||
useWith404Fallback('/webhooks', webhooks);
|
||||
|
||||
// API endpoints
|
||||
|
|
4984
client/package-lock.json
generated
4984
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -61,7 +61,7 @@
|
|||
"react-router-dom": "^4.1.1",
|
||||
"react-sortable-tree": "^1.2.0",
|
||||
"slugify": "^1.1.0",
|
||||
"url-parse": "^1.1.9"
|
||||
"url-parse": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-utils": "^11.0.1",
|
||||
|
@ -76,12 +76,12 @@
|
|||
"css-loader": "^0.28.4",
|
||||
"file-loader": "^2.0.0",
|
||||
"i18next-conv": "^3.0.3",
|
||||
"node-sass": "^4.5.3",
|
||||
"node-sass": "^4.10.0",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"raw-loader": "^0.5.1",
|
||||
"sass-loader": "^6.0.6",
|
||||
"style-loader": "^0.18.2",
|
||||
"url-loader": "^0.5.9",
|
||||
"url-loader": "^1.1.2",
|
||||
"webpack": "^2.6.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,13 +24,21 @@ import {
|
|||
} from '../lib/error-handling';
|
||||
import {getCampaignLabels} from './helpers';
|
||||
import {Table} from "../lib/table";
|
||||
import {Button} from "../lib/bootstrap-components";
|
||||
import {
|
||||
Button,
|
||||
Icon
|
||||
} from "../lib/bootstrap-components";
|
||||
import axios from "../lib/axios";
|
||||
import {getUrl, getPublicUrl} from "../lib/urls";
|
||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||
import {CampaignStatus} from "../../../shared/campaigns";
|
||||
import {
|
||||
CampaignSource,
|
||||
CampaignStatus,
|
||||
CampaignType
|
||||
} from "../../../shared/campaigns";
|
||||
import moment from 'moment';
|
||||
import campaignsStyles from "./styles.scss";
|
||||
import {tableDeleteDialogAddDeleteButton} from "../lib/modals";
|
||||
|
||||
|
||||
@translate()
|
||||
|
@ -207,10 +215,21 @@ class SendControls extends Component {
|
|||
await this.refreshEntity();
|
||||
}
|
||||
|
||||
async enableAsync() {
|
||||
await this.postAndMaskStateError(`rest/campaign-enable/${this.props.entity.id}`);
|
||||
await this.refreshEntity();
|
||||
}
|
||||
|
||||
async disableAsync() {
|
||||
await this.postAndMaskStateError(`rest/campaign-disable/${this.props.entity.id}`);
|
||||
await this.refreshEntity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const entity = this.props.entity;
|
||||
|
||||
console.log(entity);
|
||||
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
|
||||
|
||||
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers')})`;
|
||||
|
@ -266,6 +285,30 @@ class SendControls extends Component {
|
|||
</ButtonRow>
|
||||
</div>
|
||||
);
|
||||
|
||||
} else if (entity.status === CampaignStatus.INACTIVE) {
|
||||
return (
|
||||
<div>
|
||||
<AlignedRow label={t('Send status')}>
|
||||
{t('Your campaign is currently disabled. Click Enable button to start enable it.')}
|
||||
</AlignedRow>
|
||||
<ButtonRow>
|
||||
<Button className="btn-primary" icon="play" label={t('Enable')} onClickAsync={::this.enableAsync}/>
|
||||
</ButtonRow>
|
||||
</div>
|
||||
);
|
||||
|
||||
} else if (entity.status === CampaignStatus.ACTIVE) {
|
||||
return (
|
||||
<div>
|
||||
<AlignedRow label={t('Send status')}>
|
||||
{t('Your campaign is enabled and sending messages.')}
|
||||
</AlignedRow>
|
||||
<ButtonRow>
|
||||
<Button className="btn-primary" icon="stop" label={t('Disable')} onClickAsync={::this.disableAsync}/>
|
||||
</ButtonRow>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
||||
return null;
|
||||
|
@ -362,6 +405,30 @@ export default class Status extends Component {
|
|||
{ data: 3, title: t('List namespace') }
|
||||
];
|
||||
|
||||
const campaignsChildrenColumns = [
|
||||
{ data: 1, title: t('Name') },
|
||||
{ data: 2, title: t('ID'), render: data => <code>{data}</code> },
|
||||
{ data: 5, title: t('Status'), render: (data, display, rowData) => this.campaignStatusLabels[data] },
|
||||
{ data: 8, title: t('Created'), render: data => moment(data).fromNow() },
|
||||
{
|
||||
actions: data => {
|
||||
const actions = [];
|
||||
const perms = data[10];
|
||||
const campaignType = data[4];
|
||||
const campaignSource = data[7];
|
||||
|
||||
if (perms.includes('viewStats')) {
|
||||
actions.push({
|
||||
label: <Icon icon="send" title={t('Status')}/>,
|
||||
link: `/campaigns/${data[0]}/status`
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title>{t('Campaign Status')}</Title>
|
||||
|
@ -383,6 +450,15 @@ export default class Status extends Component {
|
|||
<hr/>
|
||||
|
||||
<SendControls entity={entity} refreshEntity={::this.refreshEntity}/>
|
||||
|
||||
{entity.type === CampaignType.RSS &&
|
||||
<div>
|
||||
<hr/>
|
||||
<h3>RSS Entries</h3>
|
||||
<p>{t('If a new entry is found from campaign feed a new subcampaign is created of that entry and it will be listed here')}</p>
|
||||
<Table withHeader dataUrl={`rest/campaigns-children/${this.props.entity.id}`} columns={campaignsChildrenColumns} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -31,55 +31,4 @@ export function getCampaignLabels(t) {
|
|||
};
|
||||
}
|
||||
|
||||
/* FIXME - this is not used at the moment, but it's kept here because it will be probably needed at some later point of time.
|
||||
export function getDefaultMergeTags(t) {
|
||||
return [{
|
||||
key: 'LINK_UNSUBSCRIBE',
|
||||
value: t('URL that points to the unsubscribe page')
|
||||
}, {
|
||||
key: 'LINK_PREFERENCES',
|
||||
value: t('URL that points to the preferences page of the subscriber')
|
||||
}, {
|
||||
key: 'LINK_BROWSER',
|
||||
value: t('URL to preview the message in a browser')
|
||||
}, {
|
||||
key: 'EMAIL',
|
||||
value: t('Email address')
|
||||
}, {
|
||||
key: 'SUBSCRIPTION_ID',
|
||||
value: t('Unique ID that identifies the recipient')
|
||||
}, {
|
||||
key: 'LIST_ID',
|
||||
value: t('Unique ID that identifies the list used for this campaign')
|
||||
}, {
|
||||
key: 'CAMPAIGN_ID',
|
||||
value: t('Unique ID that identifies current campaign')
|
||||
}];
|
||||
}
|
||||
|
||||
export function getRSSMergeTags(t) {
|
||||
return [{
|
||||
key: 'RSS_ENTRY',
|
||||
value: t('content from an RSS entry')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_TITLE',
|
||||
value: t('RSS entry title')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_DATE',
|
||||
value: t('RSS entry date')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_LINK',
|
||||
value: t('RSS entry link')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_CONTENT',
|
||||
value: t('content from an RSS entry')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_SUMMARY',
|
||||
value: t('RSS entry summary')
|
||||
}, {
|
||||
key: 'RSS_ENTRY_IMAGE_URL',
|
||||
value: t('RSS entry image URL')
|
||||
}];
|
||||
}
|
||||
*/
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ import {Icon, ModalDialog} from "./bootstrap-components";
|
|||
import axios from './axios';
|
||||
import styles from "./styles.scss";
|
||||
import {withPageHelpers} from "./page";
|
||||
import {getUrl} from "./urls";
|
||||
import {getUrl, getPublicUrl} from "./urls";
|
||||
|
||||
@translate()
|
||||
@withErrorHandling
|
||||
|
@ -119,9 +119,9 @@ export default class Files extends Component {
|
|||
|
||||
let downloadUrl;
|
||||
if (this.props.usePublicDownloadUrls) {
|
||||
downloadUrl =`/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${this.props.entity.id}/${data[2]}`;
|
||||
downloadUrl = getPublicUrl(`files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${this.props.entity.id}/${data[2]}`);
|
||||
} else {
|
||||
downloadUrl =`rest/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${data[0]}`;
|
||||
downloadUrl = getUrl(`rest/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${data[0]}`);
|
||||
}
|
||||
|
||||
actions.push({
|
||||
|
|
|
@ -37,7 +37,8 @@ import {
|
|||
} from '../../../../shared/imports';
|
||||
import axios from "../../lib/axios";
|
||||
import {getUrl} from "../../lib/urls";
|
||||
import styles from "../styles.scss";
|
||||
import listStyles from "../styles.scss";
|
||||
import styles from "../../lib/styles.scss";
|
||||
|
||||
|
||||
function truncate(str, len, ending = '...') {
|
||||
|
@ -372,7 +373,7 @@ export default class CUD extends Component {
|
|||
mappingSettings = (
|
||||
<div>
|
||||
{settingsRows}
|
||||
<Fieldset label={t('Mapping')} className={styles.mapping}>
|
||||
<Fieldset label={t('Mapping')} className={listStyles.mapping}>
|
||||
{mappingRows}
|
||||
</Fieldset>
|
||||
</div>
|
||||
|
|
|
@ -156,7 +156,8 @@ export default class List extends Component {
|
|||
<div>
|
||||
{tableDeleteDialogRender(this, `rest/subscriptions/${this.props.list.id}`, t('Deleting subscription ...'), t('Subscription deleted'))}
|
||||
<Toolbar>
|
||||
<a href={getPublicUrl(`subscription/${this.props.list.cid}`)} className="btn-default"><Button label={t('Subscription Form')} className="btn-default"/></a>
|
||||
<a href={getPublicUrl(`subscription/${this.props.list.cid}`)}><Button label={t('Subscription Form')} className="btn-default"/></a>
|
||||
<a href={getUrl(`subscriptions/export/${this.props.list.id}/`+ (this.props.segmentId || 0))}><Button label={t('Export as CSV')} className="btn-primary"/></a>
|
||||
<NavButton linkTo={`/lists/${this.props.list.id}/subscriptions/create`} className="btn-primary" icon="plus" label={t('Add Subscriber')}/>
|
||||
</Toolbar>
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ export default class Output extends Component {
|
|||
|
||||
@withAsyncErrorHandler
|
||||
async loadOutput() {
|
||||
const id = parseInt(this.props.match.params.id);
|
||||
const id = parseInt(this.props.match.params.reportId);
|
||||
const outputRespPromise = axios.get(getUrl(`rest/report-output/${id}`));
|
||||
const reportRespPromise = axios.get(getUrl(`rest/reports/${id}`));
|
||||
const [outputResp, reportResp] = await Promise.all([outputRespPromise, reportRespPromise]);
|
||||
|
|
|
@ -23,7 +23,7 @@ export default class View extends Component {
|
|||
|
||||
@withAsyncErrorHandler
|
||||
async loadContent() {
|
||||
const id = parseInt(this.props.match.params.id);
|
||||
const id = parseInt(this.props.match.params.reportId);
|
||||
const contentRespPromise = axios.get(getUrl(`rest/report-content/${id}`));
|
||||
const reportRespPromise = axios.get(getUrl(`rest/reports/${id}`));
|
||||
const [contentResp, reportResp] = await Promise.all([contentRespPromise, reportRespPromise]);
|
||||
|
|
|
@ -509,6 +509,69 @@ export function getEditForm(owner, typeKey, prefix = '') {
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<Trans><p>For RSS campaigns, the following further tags can be used.</p></Trans>
|
||||
<table className="table table-bordered table-condensed table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans>Merge tag</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Description</Trans>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
[RSS_ENTRY_TITLE]
|
||||
</th>
|
||||
<td>
|
||||
<Trans>RSS entry title</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
[RSS_ENTRY_DATE]
|
||||
</th>
|
||||
<td>
|
||||
<Trans>RSS entry date</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
[RSS_ENTRY_LINK]
|
||||
</th>
|
||||
<td>
|
||||
<Trans>RSS entry link</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
[RSS_ENTRY_CONTENT]
|
||||
</th>
|
||||
<td>
|
||||
<Trans>Content of an RSS entry</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
[RSS_ENTRY_SUMMARY]
|
||||
</th>
|
||||
<td>
|
||||
<Trans>RSS entry summary</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
[RSS_ENTRY_IMAGE_URL]
|
||||
</th>
|
||||
<td>
|
||||
<Trans>RSS entry image URL</Trans>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</AlignedRow>
|
||||
|
||||
|
|
|
@ -174,18 +174,18 @@ reports:
|
|||
# then it's safer to switch off the reporting functionality below.
|
||||
enabled: false
|
||||
|
||||
testserver:
|
||||
testServer:
|
||||
# Starts a vanity server that redirects all mail to /dev/null
|
||||
# Mostly needed for local development
|
||||
enabled: false
|
||||
port: 5587
|
||||
mailboxserverport: 3001
|
||||
mailboxServerPort: 3001
|
||||
host: 0.0.0.0
|
||||
username: testuser
|
||||
password: testpass
|
||||
logger: false
|
||||
|
||||
seleniumwebdriver:
|
||||
seleniumWebDriver:
|
||||
browser: phantomjs
|
||||
|
||||
|
||||
|
|
|
@ -273,7 +273,7 @@ If using VERP with iRedMail, see [this post](http://www.iredmail.org/forum/post4
|
|||
|
||||
## Testing
|
||||
|
||||
There is a built in /dev/null server in Mailtrain that you can use to load test your installation. Check the `[testserver]` section in the configuration file for details. By default the test server is disabled. The server uses only cleartext connections, so select "Do not use encryption" in the encryption settings when setting up the server data in Mailtrain.
|
||||
There is a built in /dev/null server in Mailtrain that you can use to load test your installation. Check the `[testServer]` section in the configuration file for details. By default the test server is disabled. The server uses only cleartext connections, so select "Do not use encryption" in the encryption settings when setting up the server data in Mailtrain.
|
||||
|
||||
Additionally you can generate CSV import files with fake subscriber data:
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ const lists = require('../models/lists');
|
|||
const fields = require('../models/fields');
|
||||
const sendConfigurations = require('../models/send-configurations');
|
||||
const links = require('../models/links');
|
||||
const {CampaignSource} = require('../shared/campaigns');
|
||||
const {CampaignSource, CampaignType} = require('../shared/campaigns');
|
||||
const {SubscriptionStatus} = require('../shared/lists');
|
||||
const tools = require('../lib/tools');
|
||||
const request = require('request-promise');
|
||||
|
@ -68,7 +68,7 @@ class CampaignSender {
|
|||
return prefix + 'cid:' + cid;
|
||||
});
|
||||
|
||||
html = tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, html, false, true);
|
||||
html = tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, html, true);
|
||||
|
||||
text = (text || '').trim()
|
||||
? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, text)
|
||||
|
@ -91,7 +91,7 @@ class CampaignSender {
|
|||
replyTo: getOverridable('reply_to'),
|
||||
xMailer: sendConfiguration.x_mailer ? sendConfiguration.x_mailer : false,
|
||||
to: {
|
||||
name: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false, false),
|
||||
name: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false),
|
||||
address: subscriptionGrouped.email
|
||||
},
|
||||
sender: this.useVerpSenderHeader ? campaignAddress + '@' + sendConfiguration.verp_hostname : false,
|
||||
|
@ -125,7 +125,7 @@ class CampaignSender {
|
|||
list: {
|
||||
unsubscribe: null
|
||||
},
|
||||
subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false, false),
|
||||
subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false),
|
||||
html,
|
||||
text,
|
||||
|
||||
|
@ -240,7 +240,7 @@ class CampaignSender {
|
|||
});
|
||||
}
|
||||
|
||||
html = renderTags ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, html, false, true) : html;
|
||||
html = renderTags ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, html, true) : html;
|
||||
|
||||
text = (text || '').trim()
|
||||
? (renderTags ? tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, text) : text)
|
||||
|
@ -253,12 +253,28 @@ class CampaignSender {
|
|||
};
|
||||
}
|
||||
|
||||
_getExtraTags(campaign) {
|
||||
const tags = {};
|
||||
|
||||
if (campaign.type === CampaignType.RSS_ENTRY) {
|
||||
const rssEntry = campaign.data.rssEntry;
|
||||
tags['RSS_ENTRY_TITLE'] = rssEntry.title;
|
||||
tags['RSS_ENTRY_DATE'] = rssEntry.date;
|
||||
tags['RSS_ENTRY_LINK'] = rssEntry.link;
|
||||
tags['RSS_ENTRY_CONTENT'] = rssEntry.content;
|
||||
tags['RSS_ENTRY_SUMMARY'] = rssEntry.summary;
|
||||
tags['RSS_ENTRY_IMAGE_URL'] = rssEntry.image_url;
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
async getMessage(listCid, subscriptionCid) {
|
||||
const list = this.listsByCid.get(listCid);
|
||||
const subscriptionGrouped = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, subscriptionCid);
|
||||
const flds = this.listsFieldsGrouped.get(list.id);
|
||||
const campaign = this.campaign;
|
||||
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped);
|
||||
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
|
||||
|
||||
return await this._getMessage(campaign, list, subscriptionGrouped, mergeTags, false);
|
||||
}
|
||||
|
@ -272,7 +288,8 @@ class CampaignSender {
|
|||
const subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), list.id, email);
|
||||
const flds = this.listsFieldsGrouped.get(listId);
|
||||
const campaign = this.campaign;
|
||||
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped);
|
||||
|
||||
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
|
||||
|
||||
const encryptionKeys = [];
|
||||
for (const fld of flds) {
|
||||
|
@ -314,7 +331,7 @@ class CampaignSender {
|
|||
replyTo: getOverridable('reply_to'),
|
||||
xMailer: sendConfiguration.x_mailer ? sendConfiguration.x_mailer : false,
|
||||
to: {
|
||||
name: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false, false),
|
||||
name: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, list.to_name, false),
|
||||
address: subscriptionGrouped.email
|
||||
},
|
||||
sender: this.useVerpSenderHeader ? campaignAddress + '@' + sendConfiguration.verp_hostname : false,
|
||||
|
@ -348,7 +365,7 @@ class CampaignSender {
|
|||
list: {
|
||||
unsubscribe: listUnsubscribe
|
||||
},
|
||||
subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false, false),
|
||||
subject: tools.formatMessage(campaign, list, subscriptionGrouped, mergeTags, getOverridable('subject'), false),
|
||||
html,
|
||||
text,
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ const fs = require('fs');
|
|||
const pathlib = require('path');
|
||||
const Handlebars = require('handlebars');
|
||||
|
||||
const highestLegacySchemaVersion = 29;
|
||||
const highestLegacySchemaVersion = 33;
|
||||
|
||||
const mysqlConfig = {
|
||||
multipleStatements: true
|
||||
|
@ -69,12 +69,12 @@ function getSchemaVersion(callback) {
|
|||
}
|
||||
|
||||
connection.query('SHOW TABLES LIKE "knex_migrations"', (err, rows) => {
|
||||
if (rows) {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
connection.release();
|
||||
|
||||
callback(null, highestLegacySchemaVersion);
|
||||
} else {
|
||||
|
|
|
@ -29,6 +29,10 @@ const entityTypes = {
|
|||
entitiesTable: 'campaigns',
|
||||
sharesTable: 'shares_campaign',
|
||||
permissionsTable: 'permissions_campaign',
|
||||
dependentPermissions: {
|
||||
extraColumns: ['parent'],
|
||||
getParent: entity => entity.parent
|
||||
},
|
||||
files: {
|
||||
file: {
|
||||
table: 'files_campaign_file',
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
const fork = require('child_process').fork;
|
||||
const log = require('./log');
|
||||
const path = require('path');
|
||||
const senders = require('./senders');
|
||||
|
||||
let feedcheckProcess;
|
||||
|
||||
|
@ -23,6 +24,8 @@ function spawn(callback) {
|
|||
if (msg.type === 'feedcheck-started') {
|
||||
log.info('Feed', 'Feedcheck process started');
|
||||
return callback();
|
||||
} else if (msg.type === 'entries-added') {
|
||||
senders.scheduleCheck();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -88,7 +88,7 @@ async function tryStartWorkers() {
|
|||
startWorker(report);
|
||||
|
||||
} else {
|
||||
log.info('ReportProcessor', 'No more report to start a worker for');
|
||||
log.info('ReportProcessor', 'No more reports to start a worker for');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
10
lib/tools.js
10
lib/tools.js
|
@ -108,9 +108,7 @@ function validateEmailGetMessage(result, address) {
|
|||
}
|
||||
}
|
||||
|
||||
function formatMessage(campaign, list, subscription, mergeTags, message, filter, isHTML) {
|
||||
filter = typeof filter === 'function' ? filter : (str => str);
|
||||
|
||||
function formatMessage(campaign, list, subscription, mergeTags, message, isHTML) {
|
||||
const links = getMessageLinks(campaign, list, subscription);
|
||||
|
||||
const getValue = key => {
|
||||
|
@ -135,11 +133,11 @@ function formatMessage(campaign, list, subscription, mergeTags, message, filter,
|
|||
return match;
|
||||
}
|
||||
value = (value || fallback || '').trim();
|
||||
return filter(value);
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareHtml(html) {
|
||||
async function prepareHtml(html) {
|
||||
if (!(html || '').toString().trim()) {
|
||||
return false;
|
||||
}
|
||||
|
@ -150,7 +148,7 @@ async function prepareHtml(html) {
|
|||
FetchExternalResources: false, // disables resource loading over HTTP / filesystem
|
||||
ProcessExternalResources: false // do not execute JS within script blocks
|
||||
}
|
||||
});pre
|
||||
});
|
||||
|
||||
const head = win.document.querySelector('head');
|
||||
let hasCharsetTag = false;
|
||||
|
|
|
@ -22,6 +22,7 @@ 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'];
|
||||
|
||||
const allowedKeysCreate = new Set(['type', 'source', ...allowedKeysCommon]);
|
||||
const allowedKeysCreateRssEntry = new Set(['type', 'source', 'parent', ...allowedKeysCommon]);
|
||||
const allowedKeysUpdate = new Set([...allowedKeysCommon]);
|
||||
|
||||
const Content = {
|
||||
|
@ -32,7 +33,6 @@ const Content = {
|
|||
SETTINGS_WITH_STATS: 4
|
||||
};
|
||||
|
||||
|
||||
function hash(entity, content) {
|
||||
let filteredEntity;
|
||||
|
||||
|
@ -63,11 +63,25 @@ async function listDTAjax(context, params) {
|
|||
[{ entityTypeId: 'campaign', requiredOperations: ['view'] }],
|
||||
params,
|
||||
builder => builder.from('campaigns')
|
||||
.innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace'),
|
||||
.innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace')
|
||||
.whereNull('campaigns.parent'),
|
||||
['campaigns.id', 'campaigns.name', 'campaigns.cid', 'campaigns.description', 'campaigns.type', 'campaigns.status', 'campaigns.scheduled', 'campaigns.source', 'campaigns.created', 'namespaces.name']
|
||||
);
|
||||
}
|
||||
|
||||
async function listChildrenDTAjax(context, campaignId, params) {
|
||||
return await dtHelpers.ajaxListWithPermissions(
|
||||
context,
|
||||
[{ entityTypeId: 'campaign', requiredOperations: ['view'] }],
|
||||
params,
|
||||
builder => builder.from('campaigns')
|
||||
.innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace')
|
||||
.where('campaigns.parent', campaignId),
|
||||
['campaigns.id', 'campaigns.name', 'campaigns.cid', 'campaigns.description', 'campaigns.type', 'campaigns.status', 'campaigns.scheduled', 'campaigns.source', 'campaigns.created', 'namespaces.name']
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
async function listWithContentDTAjax(context, params) {
|
||||
return await dtHelpers.ajaxListWithPermissions(
|
||||
context,
|
||||
|
@ -368,12 +382,21 @@ async function _createTx(tx, context, entity, content) {
|
|||
|
||||
await _validateAndPreprocess(tx, context, entity, true, content);
|
||||
|
||||
const filteredEntity = filterObject(entity, allowedKeysCreate);
|
||||
const filteredEntity = filterObject(entity, entity.type === CampaignType.RSS_ENTRY ? allowedKeysCreateRssEntry : allowedKeysCreate);
|
||||
filteredEntity.cid = shortid.generate();
|
||||
|
||||
const data = filteredEntity.data;
|
||||
|
||||
filteredEntity.data = JSON.stringify(filteredEntity.data);
|
||||
|
||||
if (filteredEntity.type === CampaignType.RSS || filteredEntity.type === CampaignType.TRIGGERED) {
|
||||
filteredEntity.status = CampaignStatus.ACTIVE;
|
||||
} else if (filteredEntity.type === CampaignType.RSS_ENTRY) {
|
||||
filteredEntity.status = CampaignStatus.SCHEDULED;
|
||||
} else {
|
||||
filteredEntity.status = CampaignStatus.IDLE;
|
||||
}
|
||||
|
||||
const ids = await tx('campaigns').insert(filteredEntity);
|
||||
const id = ids[0];
|
||||
|
||||
|
@ -386,7 +409,11 @@ async function _createTx(tx, context, entity, content) {
|
|||
});
|
||||
}
|
||||
|
||||
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: id });
|
||||
if (filteredEntity.parent) {
|
||||
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: id, parentId: filteredEntity.parent });
|
||||
} else {
|
||||
await shares.rebuildPermissionsTx(tx, { entityTypeId: 'campaign', entityId: id });
|
||||
}
|
||||
|
||||
if (copyFilesFrom) {
|
||||
await files.copyAllTx(tx, context, copyFilesFrom.entityType, 'file', copyFilesFrom.entityId, 'campaign', 'file', id);
|
||||
|
@ -459,29 +486,44 @@ async function updateWithConsistencyCheck(context, entity, content) {
|
|||
});
|
||||
}
|
||||
|
||||
async function _removeTx(tx, context, id, existing = null) {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'delete');
|
||||
|
||||
if (!existing) {
|
||||
existing = await tx('campaigns').where('id', id).select(['id', 'status', 'type']).first();
|
||||
}
|
||||
|
||||
if (existing.status === CampaignStatus.SENDING) {
|
||||
return new interoperableErrors.InvalidStateError;
|
||||
}
|
||||
|
||||
enforce(existing.type === CampaignType.REGULAR || existing.type === CampaignType.RSS || existing.type === CampaignType.TRIGGERED, 'This campaign cannot be removed by user.');
|
||||
|
||||
const childCampaigns = await tx('campaigns').where('parent', id).select(['id', 'status', 'type']);
|
||||
for (const childCampaign of childCampaigns) {
|
||||
await _removeTx(tx, contect, childCampaign.id, childCampaign);
|
||||
}
|
||||
|
||||
await files.removeAllTx(tx, context, 'campaign', 'file', id);
|
||||
await files.removeAllTx(tx, context, 'campaign', 'attachment', id);
|
||||
|
||||
await tx('campaign_lists').where('campaign', id).del();
|
||||
await tx('campaign_messages').where('campaign', id).del();
|
||||
await tx('campaign_links').where('campaign', id).del();
|
||||
|
||||
await triggers.removeAllByCampaignIdTx(tx, context, id);
|
||||
|
||||
await tx('template_dep_campaigns')
|
||||
.where('campaign', id)
|
||||
.del();
|
||||
|
||||
await tx('campaigns').where('id', id).del();
|
||||
}
|
||||
|
||||
|
||||
async function remove(context, id) {
|
||||
await knex.transaction(async tx => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'delete');
|
||||
|
||||
const existing = tx('campaigns').where('id', id);
|
||||
if (existing.status === CampaignStatus.SENDING) {
|
||||
return new interoperableErrors.InvalidStateError;
|
||||
}
|
||||
|
||||
await files.removeAllTx(tx, context, 'campaign', 'file', id);
|
||||
await files.removeAllTx(tx, context, 'campaign', 'attachment', id);
|
||||
|
||||
await tx('campaign_lists').where('campaign', id).del();
|
||||
await tx('campaign_messages').where('campaign', id).del();
|
||||
await tx('campaign_links').where('campaign', id).del();
|
||||
|
||||
await triggers.removeAllByCampaignIdTx(tx, context, id);
|
||||
|
||||
await tx('template_dep_campaigns')
|
||||
.where('campaign', id)
|
||||
.del();
|
||||
|
||||
await tx('campaigns').where('id', id).del();
|
||||
await _removeTx(tx, context, id);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -705,8 +747,6 @@ async function _changeStatus(context, campaignId, permittedCurrentStates, newSta
|
|||
throw new interoperableErrors.InvalidStateError(invalidStateMessage);
|
||||
}
|
||||
|
||||
console.log(scheduled);
|
||||
|
||||
await tx('campaigns').where('id', campaignId).update({
|
||||
status: newState,
|
||||
scheduled
|
||||
|
@ -747,9 +787,20 @@ async function reset(context, campaignId) {
|
|||
});
|
||||
}
|
||||
|
||||
async function enable(context, campaignId) {
|
||||
await _changeStatus(context, campaignId, [CampaignStatus.INACTIVE], CampaignStatus.ACTIVE, 'Cannot enable campaign unless it is in INACTIVE state');
|
||||
}
|
||||
|
||||
async function disable(context, campaignId) {
|
||||
await _changeStatus(context, campaignId, [CampaignStatus.ACTIVE], CampaignStatus.INACTIVE, 'Cannot disable campaign unless it is in ACTIVE state');
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports.Content = Content;
|
||||
module.exports.hash = hash;
|
||||
module.exports.listDTAjax = listDTAjax;
|
||||
module.exports.listChildrenDTAjax = listChildrenDTAjax;
|
||||
module.exports.listWithContentDTAjax = listWithContentDTAjax;
|
||||
module.exports.listOthersWhoseListsAreIncludedDTAjax = listOthersWhoseListsAreIncludedDTAjax;
|
||||
module.exports.listTestUsersDTAjax = listTestUsersDTAjax;
|
||||
|
@ -774,6 +825,8 @@ module.exports.getSubscribersQueryGeneratorTx = getSubscribersQueryGeneratorTx;
|
|||
module.exports.start = start;
|
||||
module.exports.stop = stop;
|
||||
module.exports.reset = reset;
|
||||
module.exports.enable = enable;
|
||||
module.exports.disable = disable;
|
||||
|
||||
module.exports.rawGetByTx = rawGetByTx;
|
||||
module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
|
|
@ -694,10 +694,11 @@ async function forHbs(context, listId, subscription) { // assumes grouped subscr
|
|||
return forHbsWithFieldsGrouped(flds, subscription);
|
||||
}
|
||||
|
||||
function getMergeTags(fieldsGrouped, subscription) { // assumes grouped subscription
|
||||
function getMergeTags(fieldsGrouped, subscription, extraTags = {}) { // assumes grouped subscription
|
||||
const mergeTags = {
|
||||
'EMAIL': subscription.email,
|
||||
...getMergeTagsForBases(getTrustedUrl(), getSandboxUrl(), getPublicUrl())
|
||||
...getMergeTagsForBases(getTrustedUrl(), getSandboxUrl(), getPublicUrl()),
|
||||
...extraTags
|
||||
};
|
||||
|
||||
for (const fld of fieldsGrouped) {
|
||||
|
|
|
@ -195,24 +195,6 @@ async function remove(context, id) {
|
|||
});
|
||||
}
|
||||
|
||||
async function getMergeTags(context, id) {
|
||||
return await knex.transaction(async tx => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'list', id, ['view']);
|
||||
const groupedFields = await fields.listGroupedTx(tx, id);
|
||||
|
||||
const mergeTags = [];
|
||||
for (const field of groupedFields) {
|
||||
mergeTags.push({
|
||||
key: field.key,
|
||||
value: field.name
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
return mergeTags;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
module.exports.UnsubscriptionMode = UnsubscriptionMode;
|
||||
module.exports.hash = hash;
|
||||
|
@ -226,4 +208,3 @@ module.exports.getByCid = getByCid;
|
|||
module.exports.create = create;
|
||||
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
|
||||
module.exports.remove = remove;
|
||||
module.exports.getMergeTags = getMergeTags;
|
||||
|
|
|
@ -20,7 +20,7 @@ function hash(entity) {
|
|||
return hasher.hash(filterObject(entity, allowedKeys));
|
||||
}
|
||||
|
||||
async function getByIdWithTemplate(context, id) {
|
||||
async function getByIdWithTemplate(context, id, withPermissions = true) {
|
||||
return await knex.transaction(async tx => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'report', id, 'view');
|
||||
|
||||
|
@ -33,7 +33,9 @@ async function getByIdWithTemplate(context, id) {
|
|||
entity.user_fields = JSON.parse(entity.user_fields);
|
||||
entity.params = JSON.parse(entity.params);
|
||||
|
||||
entity.permissions = await shares.getPermissionsTx(tx, context, 'report', id);
|
||||
if (withPermissions) {
|
||||
entity.permissions = await shares.getPermissionsTx(tx, context, 'report', id);
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
|
|
@ -100,7 +100,14 @@ async function assign(context, entityTypeId, entityId, userId, role) {
|
|||
await enforceEntityPermissionTx(tx, context, entityTypeId, entityId, 'share');
|
||||
|
||||
enforce(await tx('users').where('id', userId).select('id').first(), 'Invalid user id');
|
||||
enforce(await tx(entityType.entitiesTable).where('id', entityId).select('id').first(), 'Invalid entity id');
|
||||
|
||||
const extraColumns = entityType.dependentPermissions ? entityType.dependentPermissions.extraColumns : [];
|
||||
const entity = await tx(entityType.entitiesTable).where('id', entityId).select(['id', ...extraColumns]).first();
|
||||
enforce(entity, 'Invalid entity id');
|
||||
|
||||
if (entityType.dependentPermissions) {
|
||||
enforce(!entityType.dependentPermissions.getParent(entity), 'Cannot share/unshare a dependent entity');
|
||||
}
|
||||
|
||||
const entry = await tx(entityType.sharesTable).where({user: userId, entity: entityId}).select('role').first();
|
||||
|
||||
|
@ -310,13 +317,51 @@ async function rebuildPermissionsTx(tx, restriction) {
|
|||
}
|
||||
await expungeQuery;
|
||||
|
||||
const entitiesQuery = tx(entityType.entitiesTable).select(['id', 'namespace']);
|
||||
const extraColumns = entityType.dependentPermissions ? entityType.dependentPermissions.extraColumns : [];
|
||||
const entitiesQuery = tx(entityType.entitiesTable).select(['id', 'namespace', ...extraColumns]);
|
||||
|
||||
|
||||
const notToBeInserted = new Set();
|
||||
if (restriction.entityId) {
|
||||
entitiesQuery.where('id', restriction.entityId);
|
||||
if (restriction.parentId) {
|
||||
notToBeInserted.add(restriction.parentId);
|
||||
entitiesQuery.whereIn('id', [restriction.entityId, restriction.parentId]);
|
||||
} else {
|
||||
entitiesQuery.where('id', restriction.entityId);
|
||||
}
|
||||
}
|
||||
const entities = await entitiesQuery;
|
||||
|
||||
for (const entity of entities) {
|
||||
// TODO - process restriction.parentId
|
||||
|
||||
const parentEntities = new Map();
|
||||
let nonChildEntities;
|
||||
if (entityType.dependentPermissions) {
|
||||
nonChildEntities = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const parent = entityType.dependentPermissions.getParent(entity);
|
||||
|
||||
if (parent) {
|
||||
let childEntities;
|
||||
if (parentEntities.has(parent)) {
|
||||
childEntities = parentEntities.get(parent);
|
||||
} else {
|
||||
childEntities = [];
|
||||
parentEntities.set(parent, childEntities);
|
||||
}
|
||||
|
||||
childEntities.push(entity.id);
|
||||
} else {
|
||||
nonChildEntities.push(entity);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nonChildEntities = entities;
|
||||
}
|
||||
|
||||
|
||||
for (const entity of nonChildEntities) {
|
||||
const permsPerUser = new Map();
|
||||
|
||||
if (entity.namespace) { // The root namespace has not parent namespace, thus the test
|
||||
|
@ -350,15 +395,37 @@ async function rebuildPermissionsTx(tx, restriction) {
|
|||
}
|
||||
}
|
||||
|
||||
for (const userPermsPair of permsPerUser.entries()) {
|
||||
const data = [];
|
||||
if (!notToBeInserted.has(entity.id)) {
|
||||
for (const userPermsPair of permsPerUser.entries()) {
|
||||
const data = [];
|
||||
|
||||
for (const operation of userPermsPair[1]) {
|
||||
data.push({user: userPermsPair[0], entity: entity.id, operation});
|
||||
for (const operation of userPermsPair[1]) {
|
||||
data.push({user: userPermsPair[0], entity: entity.id, operation});
|
||||
}
|
||||
|
||||
if (data.length > 0) {
|
||||
await tx(entityType.permissionsTable).insert(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length > 0) {
|
||||
await tx(entityType.permissionsTable).insert(data);
|
||||
if (parentEntities.has(entity.id)) {
|
||||
const childEntities = parentEntities.get(entity.id);
|
||||
|
||||
for (const childId of childEntities) {
|
||||
for (const userPermsPair of permsPerUser.entries()) {
|
||||
const data = [];
|
||||
|
||||
for (const operation of userPermsPair[1]) {
|
||||
if (operation !== 'share') {
|
||||
data.push({user: userPermsPair[0], entity: childId, operation});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length > 0) {
|
||||
await tx(entityType.permissionsTable).insert(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,7 +81,7 @@ function getSubscriptionTableName(listId) {
|
|||
return `subscription__${listId}`;
|
||||
}
|
||||
|
||||
async function getGroupedFieldsMap(tx, listId) {
|
||||
async function getGroupedFieldsMapTx(tx, listId) {
|
||||
const groupedFields = await fields.listGroupedTx(tx, listId);
|
||||
const result = {};
|
||||
for (const fld of groupedFields) {
|
||||
|
@ -189,7 +189,7 @@ function hashByAllowedKeys(allowedKeys, entity) {
|
|||
|
||||
async function hashByList(listId, entity) {
|
||||
return await knex.transaction(async tx => {
|
||||
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
|
||||
const allowedKeys = getAllowedKeys(groupedFieldsMap);
|
||||
return hashByAllowedKeys(allowedKeys, entity);
|
||||
});
|
||||
|
@ -204,7 +204,7 @@ async function _getByTx(tx, context, listId, key, value, grouped) {
|
|||
throw new interoperableErrors.NotFoundError('Subscription not found in this list');
|
||||
}
|
||||
|
||||
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
|
||||
|
||||
if (grouped) {
|
||||
groupSubscription(groupedFieldsMap, entity);
|
||||
|
@ -248,7 +248,7 @@ async function listDTAjax(context, listId, segmentId, params) {
|
|||
// to group the fields. Then we copy relevant values form grouped subscription to ajaxList's data which then get
|
||||
// returned to the client. During the copy, we also render the values.
|
||||
|
||||
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
|
||||
const listFlds = await fields.listByOrderListTx(tx, listId, ['column', 'id']);
|
||||
|
||||
const columns = [
|
||||
|
@ -387,7 +387,7 @@ async function list(context, listId, grouped = true, offset, limit) {
|
|||
const entities = await entitiesQry;
|
||||
|
||||
if (grouped) {
|
||||
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
|
||||
|
||||
for (const entity of entities) {
|
||||
groupSubscription(groupedFieldsMap, entity);
|
||||
|
@ -401,6 +401,48 @@ async function list(context, listId, grouped = true, offset, limit) {
|
|||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
let addSegmentQuery;
|
||||
|
||||
await knex.transaction(async tx => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
|
||||
|
||||
if (grouped) {
|
||||
groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
|
||||
}
|
||||
|
||||
addSegmentQuery = segmentId ? await segments.getQueryGeneratorTx(tx, listId, segmentId) : () => {};
|
||||
});
|
||||
|
||||
let lastId = 0;
|
||||
|
||||
while (true) {
|
||||
const entities = await knex(getSubscriptionTableName(listId))
|
||||
.orderBy('id', 'asc')
|
||||
.where('id', '>', lastId)
|
||||
.where(function() {
|
||||
addSegmentQuery(this);
|
||||
})
|
||||
.limit(500);
|
||||
|
||||
if (entities.length > 0) {
|
||||
for (const entity of entities) {
|
||||
if (grouped) {
|
||||
groupSubscription(groupedFieldsMap, entity);
|
||||
}
|
||||
|
||||
yield entity;
|
||||
}
|
||||
|
||||
lastId = entities[entities.length - 1].id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function serverValidate(context, listId, data) {
|
||||
return await knex.transaction(async tx => {
|
||||
const result = {};
|
||||
|
@ -563,7 +605,7 @@ async function createTxWithGroupedFieldsMap(tx, context, listId, groupedFieldsMa
|
|||
|
||||
async function create(context, listId, entity, source, meta) {
|
||||
return await knex.transaction(async tx => {
|
||||
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
|
||||
return await createTxWithGroupedFieldsMap(tx, context, listId, groupedFieldsMap, entity, source, meta);
|
||||
});
|
||||
}
|
||||
|
@ -577,7 +619,7 @@ async function updateWithConsistencyCheck(context, listId, entity, source) {
|
|||
throw new interoperableErrors.NotFoundError();
|
||||
}
|
||||
|
||||
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
|
||||
const allowedKeys = getAllowedKeys(groupedFieldsMap);
|
||||
|
||||
groupSubscription(groupedFieldsMap, existing);
|
||||
|
@ -718,7 +760,7 @@ async function updateManaged(context, listId, cid, entity) {
|
|||
await knex.transaction(async tx => {
|
||||
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
|
||||
|
||||
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
|
||||
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
|
||||
|
||||
const update = {};
|
||||
for (const key in groupedFieldsMap) {
|
||||
|
@ -764,11 +806,12 @@ module.exports.getByCidTx = getByCidTx;
|
|||
module.exports.getByCid = getByCid;
|
||||
module.exports.getByEmail = getByEmail;
|
||||
module.exports.list = list;
|
||||
module.exports.listIterator = listIterator;
|
||||
module.exports.listDTAjax = listDTAjax;
|
||||
module.exports.listTestUsersDTAjax = listTestUsersDTAjax;
|
||||
module.exports.serverValidate = serverValidate;
|
||||
module.exports.create = create;
|
||||
module.exports.getGroupedFieldsMap = getGroupedFieldsMap;
|
||||
module.exports.getGroupedFieldsMapTx = getGroupedFieldsMapTx;
|
||||
module.exports.createTxWithGroupedFieldsMap = createTxWithGroupedFieldsMap;
|
||||
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
|
||||
module.exports.remove = remove;
|
||||
|
|
6593
package-lock.json
generated
6593
package-lock.json
generated
File diff suppressed because it is too large
Load diff
17
package.json
17
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "mailtrain",
|
||||
"private": true,
|
||||
"version": "1.24.0",
|
||||
"version": "2.0.0",
|
||||
"description": "Self hosted email newsletter app",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -21,11 +21,10 @@
|
|||
"type": "git",
|
||||
"url": "git://github.com/Mailtrain-org/mailtrain.git"
|
||||
},
|
||||
"author": "Andris Reinman",
|
||||
"license": "GPL-3.0",
|
||||
"homepage": "https://mailtrain.org/",
|
||||
"engines": {
|
||||
"node": ">=5.0.0"
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-eslint": "^8.1.2",
|
||||
|
@ -33,9 +32,7 @@
|
|||
"eslint-config-nodemailer": "^1.2.0",
|
||||
"grunt": "^1.0.3",
|
||||
"grunt-cli": "^1.2.0",
|
||||
"grunt-contrib-nodeunit": "^2.0.0",
|
||||
"grunt-eslint": "^20.1.0",
|
||||
"jsxgettext-andris": "^0.9.0-patch.1",
|
||||
"lodash": "^4.17.10",
|
||||
"mocha": "^5.2.0",
|
||||
"phantomjs-prebuilt": "^2.1.15",
|
||||
|
@ -43,7 +40,7 @@
|
|||
"url-pattern": "^1.0.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"posix": "^4.1.1"
|
||||
"posix": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-sdk": "^2.307.0",
|
||||
|
@ -58,7 +55,8 @@
|
|||
"cors": "^2.8.4",
|
||||
"crypto": "^1.0.1",
|
||||
"csurf": "^1.9.0",
|
||||
"csv-parse": "^1.2.3",
|
||||
"csv-parse": "^1.3.3",
|
||||
"csv-stringify": "^4.3.1",
|
||||
"device": "^0.3.8",
|
||||
"dompurify": "^1.0.2",
|
||||
"escape-html": "^1.0.3",
|
||||
|
@ -87,7 +85,7 @@
|
|||
"mjml": "^4.2.0",
|
||||
"moment": "^2.18.1",
|
||||
"moment-timezone": "^0.5.13",
|
||||
"morgan": "^1.8.2",
|
||||
"morgan": "^1.9.1",
|
||||
"multer": "^1.3.0",
|
||||
"mysql2": "^1.3.5",
|
||||
"node-gettext": "^2.0.0-rc.1",
|
||||
|
@ -110,6 +108,7 @@
|
|||
"slugify": "^1.2.8",
|
||||
"smtp-server": "^3.1.0",
|
||||
"toml": "^2.3.3",
|
||||
"try-require": "^1.2.1"
|
||||
"try-require": "^1.2.1",
|
||||
"xmldom": "^0.1.27"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ const router = require('../lib/router-async').create();
|
|||
const links = require('../models/links');
|
||||
const interoperableErrors = require('../shared/interoperable-errors');
|
||||
|
||||
const trackImg = new Buffer('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
|
||||
const trackImg = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
|
||||
|
||||
router.getAsync('/:campaign/:list/:subscription', async (req, res) => {
|
||||
res.writeHead(200, {
|
||||
|
|
|
@ -8,6 +8,11 @@ const contextHelpers = require('../lib/context-helpers');
|
|||
|
||||
const router = require('../lib/router-async').create();
|
||||
|
||||
const fileSuffixes = {
|
||||
'text/html': '.html',
|
||||
'text/csv': '.csv'
|
||||
};
|
||||
|
||||
router.getAsync('/:id/download', passport.loggedIn, async (req, res) => {
|
||||
await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewContent');
|
||||
|
||||
|
@ -15,7 +20,7 @@ router.getAsync('/:id/download', passport.loggedIn, async (req, res) => {
|
|||
|
||||
if (report.state == reports.ReportState.FINISHED) {
|
||||
const headers = {
|
||||
'Content-Disposition': 'attachment;filename=' + reportHelpers.nameToFileName(report.name) + '.csv',
|
||||
'Content-Disposition': 'attachment;filename=' + reportHelpers.nameToFileName(report.name) + (fileSuffixes[report.mime_type] || ''),
|
||||
'Content-Type': report.mime_type
|
||||
};
|
||||
|
||||
|
|
|
@ -19,6 +19,10 @@ router.postAsync('/campaigns-others-by-list-table/:campaignId/:listIds', passpor
|
|||
return res.json(await campaigns.listOthersWhoseListsAreIncludedDTAjax(req.context, castToInteger(req.params.campaignId), req.params.listIds.split(';').map(x => castToInteger(x)), req.body));
|
||||
});
|
||||
|
||||
router.postAsync('/campaigns-children/:campaignId', passport.loggedIn, async (req, res) => {
|
||||
return res.json(await campaigns.listChildrenDTAjax(req.context, castToInteger(req.params.campaignId), req.body));
|
||||
});
|
||||
|
||||
router.postAsync('/campaigns-test-users-table/:campaignId', passport.loggedIn, async (req, res) => {
|
||||
return res.json(await campaigns.listTestUsersDTAjax(req.context, castToInteger(req.params.campaignId), req.body));
|
||||
});
|
||||
|
@ -82,4 +86,13 @@ router.postAsync('/campaign-reset/:campaignId', passport.loggedIn, passport.csrf
|
|||
return res.json(await campaigns.reset(req.context, castToInteger(req.params.campaignId)));
|
||||
});
|
||||
|
||||
router.postAsync('/campaign-enable/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||
return res.json(await campaigns.enable(req.context, castToInteger(req.params.campaignId), null));
|
||||
});
|
||||
|
||||
router.postAsync('/campaign-disable/:campaignId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||
return res.json(await campaigns.disable(req.context, castToInteger(req.params.campaignId), null));
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
|
@ -10,7 +10,7 @@ const contextHelpers = require('../../lib/context-helpers');
|
|||
|
||||
const router = require('../../lib/router-async').create();
|
||||
const {castToInteger} = require('../../lib/helpers');
|
||||
|
||||
const fs = require('fs-extra');
|
||||
|
||||
router.getAsync('/reports/:reportId', passport.loggedIn, async (req, res) => {
|
||||
const report = await reports.getByIdWithTemplate(req.context, castToInteger(req.params.reportId));
|
||||
|
@ -44,7 +44,7 @@ router.postAsync('/report-start/:id', passport.loggedIn, passport.csrfProtection
|
|||
|
||||
await shares.enforceEntityPermission(req.context, 'report', id, 'execute');
|
||||
|
||||
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), id);
|
||||
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), id, false);
|
||||
await shares.enforceEntityPermission(req.context, 'reportTemplate', report.report_template, 'execute');
|
||||
|
||||
await reportProcessor.start(id);
|
||||
|
@ -56,7 +56,7 @@ router.postAsync('/report-stop/:id', async (req, res) => {
|
|||
|
||||
await shares.enforceEntityPermission(req.context, 'report', id, 'execute');
|
||||
|
||||
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), id);
|
||||
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), id, false);
|
||||
await shares.enforceEntityPermission(req.context, 'reportTemplate', report.report_template, 'execute');
|
||||
|
||||
await reportProcessor.stop(id);
|
||||
|
@ -68,8 +68,14 @@ router.getAsync('/report-content/:id', async (req, res) => {
|
|||
|
||||
await shares.enforceEntityPermission(req.context, 'report', id, 'viewContent');
|
||||
|
||||
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), id);
|
||||
res.sendFile(reportHelpers.getReportContentFile(report));
|
||||
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), id, false);
|
||||
const file = reportHelpers.getReportContentFile(report);
|
||||
|
||||
if (await fs.pathExists(file)) {
|
||||
res.sendFile(file);
|
||||
} else {
|
||||
res.send('');
|
||||
}
|
||||
});
|
||||
|
||||
router.getAsync('/report-output/:id', async (req, res) => {
|
||||
|
@ -77,8 +83,14 @@ router.getAsync('/report-output/:id', async (req, res) => {
|
|||
|
||||
await shares.enforceEntityPermission(req.context, 'report', id, 'viewOutput');
|
||||
|
||||
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), id);
|
||||
res.sendFile(reportHelpers.getReportOutputFile(report));
|
||||
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), id, false);
|
||||
const file = reportHelpers.getReportOutputFile(report);
|
||||
|
||||
if (await fs.pathExists(file)) {
|
||||
res.sendFile(file);
|
||||
} else {
|
||||
res.send('');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
|
59
routes/subscriptions.js
Normal file
59
routes/subscriptions.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
'use strict';
|
||||
|
||||
const passport = require('../lib/passport');
|
||||
const shares = require('../models/shares');
|
||||
const contextHelpers = require('../lib/context-helpers');
|
||||
const router = require('../lib/router-async').create();
|
||||
const subscriptions = require('../models/subscriptions');
|
||||
const {castToInteger} = require('../lib/helpers');
|
||||
const stringify = require('csv-stringify')
|
||||
const fields = require('../models/fields');
|
||||
const lists = require('../models/lists');
|
||||
const moment = require('moment');
|
||||
|
||||
router.getAsync('/export/:listId/:segmentId', passport.loggedIn, async (req, res) => {
|
||||
const listId = castToInteger(req.params.listId);
|
||||
const segmentId = castToInteger(req.params.segmentId);
|
||||
|
||||
const flds = await fields.list(req.context, listId);
|
||||
|
||||
const columns = [
|
||||
{key: 'cid', header: 'cid'},
|
||||
{key: 'hash_email', header: 'HASH_EMAIL'},
|
||||
{key: 'email', header: 'EMAIL'},
|
||||
];
|
||||
|
||||
for (const fld of flds) {
|
||||
if (fld.column) {
|
||||
columns.push({
|
||||
key: fld.column,
|
||||
header: fld.key
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const list = await lists.getById(req.context, listId);
|
||||
|
||||
const headers = {
|
||||
'Content-Disposition': `attachment;filename=subscriptions-${list.cid}-${segmentId}-${moment().toISOString()}.csv`,
|
||||
'Content-Type': 'text/csv'
|
||||
};
|
||||
|
||||
res.set(headers);
|
||||
|
||||
const stringifier = stringify({
|
||||
columns,
|
||||
header: true,
|
||||
delimiter: ','
|
||||
});
|
||||
|
||||
stringifier.pipe(res);
|
||||
|
||||
for await (const subscription of subscriptions.listIterator(req.context, listId, segmentId, false)) {
|
||||
stringifier.write(subscription);
|
||||
}
|
||||
|
||||
stringifier.end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
|
@ -60,7 +60,7 @@ async function run() {
|
|||
.select('id')
|
||||
.first()) {
|
||||
|
||||
const rssCampaign = campaigns.getById(contextHelpers.getAdminContext(), rssCampaignIdRow.id);
|
||||
const rssCampaign = await campaigns.getById(contextHelpers.getAdminContext(), rssCampaignIdRow.id, false);
|
||||
|
||||
let checkStatus = null;
|
||||
|
||||
|
@ -92,6 +92,7 @@ async function run() {
|
|||
campaignData.rssEntry = entry;
|
||||
|
||||
const campaign = {
|
||||
parent: rssCampaign.id,
|
||||
type: CampaignType.RSS_ENTRY,
|
||||
source,
|
||||
name: entry.title || `RSS entry ${entry.guid.substr(0, 67)}`,
|
||||
|
@ -103,7 +104,7 @@ async function run() {
|
|||
from_email_override: rssCampaign.from_email_override,
|
||||
reply_to_override: rssCampaign.reply_to_override,
|
||||
subject_override: rssCampaign.subject_override,
|
||||
data: JSON.stringify(campaignData),
|
||||
data: campaignData,
|
||||
|
||||
click_tracking_disabled: rssCampaign.click_tracking_disabled,
|
||||
open_tracking_disabled: rssCampaign.open_tracking_disabled,
|
||||
|
@ -126,8 +127,12 @@ async function run() {
|
|||
}
|
||||
|
||||
if (added > 0) {
|
||||
checkStatus = util.format(_('Found %s new campaign messages from feed'), added);
|
||||
log.verbose('Feed', `Added ${added} new campaigns for ${rssCampaign.id}`);
|
||||
checkStatus = util.format(_('Found %s new campaign messages from feed %s'), added, rssCampaign.id);
|
||||
log.verbose('Feed', `Found ${added} new campaigns messages from feed ${rssCampaign.id}`);
|
||||
|
||||
process.send({
|
||||
type: 'entries-added'
|
||||
});
|
||||
} else {
|
||||
checkStatus = _('Found nothing new from the feed');
|
||||
}
|
||||
|
|
|
@ -177,7 +177,7 @@ async function _execImportRun(impt, handlers) {
|
|||
lastId = rows[rows.length - 1].id;
|
||||
|
||||
await knex.transaction(async tx => {
|
||||
const groupedFieldsMap = await subscriptions.getGroupedFieldsMap(tx, impt.list);
|
||||
const groupedFieldsMap = await subscriptions.getGroupedFieldsMapTx(tx, impt.list);
|
||||
|
||||
let newRows = 0;
|
||||
|
||||
|
|
|
@ -107,58 +107,63 @@ async function processCampaign(campaignId) {
|
|||
const msgQueue = [];
|
||||
messageQueue.set(campaignId, msgQueue);
|
||||
|
||||
while (true) {
|
||||
const cpg = await knex('campaigns').where('id', campaignId).first();
|
||||
try {
|
||||
while (true) {
|
||||
const cpg = await knex('campaigns').where('id', campaignId).first();
|
||||
|
||||
if (cpg.status === CampaignStatus.PAUSED) {
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
let qryGen;
|
||||
await knex.transaction(async tx => {
|
||||
qryGen = await campaigns.getSubscribersQueryGeneratorTx(tx, campaignId, true);
|
||||
});
|
||||
|
||||
if (qryGen) {
|
||||
let subscribersInProcessing = [...msgQueue];
|
||||
for (const wa of workAssignment.values()) {
|
||||
if (wa.campaignId === campaignId) {
|
||||
subscribersInProcessing = subscribersInProcessing.concat(wa.subscribers);
|
||||
}
|
||||
}
|
||||
|
||||
const qry = qryGen(knex)
|
||||
.whereNotIn('pending_subscriptions.email', subscribersInProcessing.map(x => x.email))
|
||||
.select(['pending_subscriptions.email', 'campaign_lists.list'])
|
||||
.limit(retrieveBatchSize);
|
||||
const subs = await qry;
|
||||
|
||||
if (subs.length === 0) {
|
||||
await finish();
|
||||
if (cpg.status === CampaignStatus.PAUSED) {
|
||||
messageQueue.delete(campaignId);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sub of subs) {
|
||||
msgQueue.push({
|
||||
listId: sub.list,
|
||||
email: sub.email
|
||||
});
|
||||
}
|
||||
|
||||
const nextBatchNeeded = new Promise(resolve => {
|
||||
messageQueueCont.set(campaignId, resolve);
|
||||
let qryGen;
|
||||
await knex.transaction(async tx => {
|
||||
qryGen = await campaigns.getSubscribersQueryGeneratorTx(tx, campaignId, true);
|
||||
});
|
||||
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
setImmediate(scheduleWorkers);
|
||||
if (qryGen) {
|
||||
let subscribersInProcessing = [...msgQueue];
|
||||
for (const wa of workAssignment.values()) {
|
||||
if (wa.campaignId === campaignId) {
|
||||
subscribersInProcessing = subscribersInProcessing.concat(wa.subscribers);
|
||||
}
|
||||
}
|
||||
|
||||
await nextBatchNeeded;
|
||||
const qry = qryGen(knex)
|
||||
.whereNotIn('pending_subscriptions.email', subscribersInProcessing.map(x => x.email))
|
||||
.select(['pending_subscriptions.email', 'campaign_lists.list'])
|
||||
.limit(retrieveBatchSize);
|
||||
const subs = await qry;
|
||||
|
||||
} else {
|
||||
await finish();
|
||||
return;
|
||||
if (subs.length === 0) {
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sub of subs) {
|
||||
msgQueue.push({
|
||||
listId: sub.list,
|
||||
email: sub.email
|
||||
});
|
||||
}
|
||||
|
||||
const nextBatchNeeded = new Promise(resolve => {
|
||||
messageQueueCont.set(campaignId, resolve);
|
||||
});
|
||||
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
setImmediate(scheduleWorkers);
|
||||
|
||||
await nextBatchNeeded;
|
||||
|
||||
} else {
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Senders', `Sending campaign ${campaignId} failed with error: ${err.message}`)
|
||||
log.verbose(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ const mailstore = {
|
|||
const server = new SMTPServer({
|
||||
|
||||
// log to console
|
||||
logger: config.testserver.logger,
|
||||
logger: config.testServer.logger,
|
||||
|
||||
// not required but nice-to-have
|
||||
banner: 'Welcome to My Awesome SMTP Server',
|
||||
|
@ -55,8 +55,8 @@ const server = new SMTPServer({
|
|||
|
||||
// Setup authentication
|
||||
onAuth: (auth, session, callback) => {
|
||||
let username = config.testserver.username;
|
||||
let password = config.testserver.password;
|
||||
let username = config.testServer.username;
|
||||
let password = config.testServer.password;
|
||||
|
||||
// check username and password
|
||||
if (auth.username === username && auth.password === password) {
|
||||
|
@ -169,9 +169,9 @@ mailBoxServer.on('error', err => {
|
|||
});
|
||||
|
||||
module.exports = callback => {
|
||||
if (config.testserver.enabled) {
|
||||
server.listen(config.testserver.port, config.testserver.host, () => {
|
||||
log.info('Test SMTP', 'Server listening on port %s', config.testserver.port);
|
||||
if (config.testServer.enabled) {
|
||||
server.listen(config.testServer.port, config.testServer.host, () => {
|
||||
log.info('Test SMTP', 'Server listening on port %s', config.testServer.port);
|
||||
|
||||
setInterval(() => {
|
||||
if (received) {
|
||||
|
@ -186,8 +186,8 @@ module.exports = callback => {
|
|||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
mailBoxServer.listen(config.testserver.mailboxserverport, config.testserver.host, () => {
|
||||
log.info('Test SMTP', 'Mail Box Server listening on port %s', config.testserver.mailboxserverport);
|
||||
mailBoxServer.listen(config.testServer.mailboxServerPort, config.testServer.host, () => {
|
||||
log.info('Test SMTP', 'Mail Box Server listening on port %s', config.testServer.mailboxServerPort);
|
||||
setImmediate(callback);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,8 @@ const { enforce } = require('../../../lib/helpers');
|
|||
const { EntityVals: TriggerEntityVals, EventVals: TriggerEventVals } = require('../../../shared/triggers');
|
||||
const { SubscriptionSource } = require('../../../shared/lists');
|
||||
const crypto = require('crypto');
|
||||
const {DOMParser, XMLSerializer} = require('xmldom');
|
||||
const log = require('../../../lib/log');
|
||||
|
||||
const entityTypesAddNamespace = ['list', 'custom_form', 'template', 'campaign', 'report', 'report_template', 'user'];
|
||||
const shareableEntityTypes = ['list', 'custom_form', 'template', 'campaign', 'report', 'report_template', 'namespace', 'send_configuration', 'mosaico_template'];
|
||||
|
@ -250,18 +252,27 @@ async function migrateSubscriptions(knex) {
|
|||
}
|
||||
}
|
||||
|
||||
const subscriptionsStream = knex('subscription__' + list.id).stream();
|
||||
let subscription;
|
||||
while ((subscription = subscriptionsStream.read()) != null) {
|
||||
subscription.hash_email = crypto.createHash('sha512').update(subscription.email).digest("base64");
|
||||
subscription.source_email = subscription.imported ? SubscriptionSource.IMPORTED_V1 : SubscriptionSource.NOT_IMPORTED_V1;
|
||||
for (const field of fields) {
|
||||
if (field.column != null) {
|
||||
subscription['source_' + field.column] = subscription.imported ? SubscriptionSource.IMPORTED_V1 : SubscriptionSource.NOT_IMPORTED_V1;
|
||||
}
|
||||
}
|
||||
let lastId = 0;
|
||||
while (true) {
|
||||
const rows = await knex('subscription__' + list.id).where('id', '>', lastId).orderBy('id', 'asc').limit(1000);
|
||||
|
||||
await knex('subscription__' + list.id).where('id', subscription.id).update(subscription);
|
||||
if (rows.length > 0) {
|
||||
for await (const subscription of rows) {
|
||||
subscription.hash_email = crypto.createHash('sha512').update(subscription.email).digest("base64");
|
||||
subscription.source_email = subscription.imported ? SubscriptionSource.IMPORTED_V1 : SubscriptionSource.NOT_IMPORTED_V1;
|
||||
for (const field of fields) {
|
||||
if (field.column != null) {
|
||||
subscription['source_' + field.column] = subscription.imported ? SubscriptionSource.IMPORTED_V1 : SubscriptionSource.NOT_IMPORTED_V1;
|
||||
}
|
||||
}
|
||||
|
||||
await knex('subscription__' + list.id).where('id', subscription.id).update(subscription);
|
||||
}
|
||||
|
||||
lastId = rows[rows.length - 1].id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` MODIFY `hash_email` varchar(255) CHARACTER SET ascii NOT NULL');
|
||||
|
@ -269,6 +280,8 @@ async function migrateSubscriptions(knex) {
|
|||
await knex.schema.table('subscription__' + list.id, table => {
|
||||
table.dropColumn('imported');
|
||||
});
|
||||
|
||||
log.verbose('Migration', 'Subscriptions for list ' + list.cid + ' complete');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -504,8 +517,8 @@ async function migrateSegments(knex) {
|
|||
if (oldSettings.range) {
|
||||
if (oldSettings.start && oldSettings.end) {
|
||||
if (type === 'all') {
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start});
|
||||
rules.push({ type: 'lt', column: oldRule.column, value: oldSettings.end});
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start });
|
||||
rules.push({ type: 'lt', column: oldRule.column, value: oldSettings.end });
|
||||
} else {
|
||||
rules.push({
|
||||
type: 'all',
|
||||
|
@ -516,87 +529,91 @@ async function migrateSegments(knex) {
|
|||
});
|
||||
}
|
||||
} else if (oldSettings.start) {
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start });
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start });
|
||||
}
|
||||
if (oldSettings.end) {
|
||||
rules.push({ type: 'lt', column: oldRule.column, value: oldSettings.end });
|
||||
rules.push({ type: 'lt', column: oldRule.column, value: oldSettings.end });
|
||||
}
|
||||
} else {
|
||||
rules.push({ type: 'eq', column: oldRule.column, value: oldSettings.value });
|
||||
rules.push({ type: 'eq', column: oldRule.column, value: oldSettings.value });
|
||||
}
|
||||
break;
|
||||
case 'birthday':
|
||||
if (oldSettings.range) {
|
||||
if (oldSettings.start && oldSettings.end) {
|
||||
if (type === 'all') {
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start});
|
||||
rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end});
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start });
|
||||
rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end });
|
||||
} else {
|
||||
rules.push({
|
||||
type: 'all',
|
||||
rules: [
|
||||
{ type: 'ge', column: oldRule.column, value: oldSettings.start},
|
||||
{ type: 'le', column: oldRule.column, value: oldSettings.end}
|
||||
{ type: 'ge', column: oldRule.column, value: oldSettings.start },
|
||||
{ type: 'le', column: oldRule.column, value: oldSettings.end }
|
||||
]
|
||||
});
|
||||
}
|
||||
} else if (oldSettings.start) {
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start });
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start });
|
||||
}
|
||||
if (oldSettings.end) {
|
||||
rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end });
|
||||
rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end });
|
||||
}
|
||||
} else {
|
||||
rules.push({ type: 'eq', column: oldRule.column, value: oldSettings.value });
|
||||
rules.push({ type: 'eq', column: oldRule.column, value: oldSettings.value });
|
||||
}
|
||||
break;
|
||||
case 'date':
|
||||
if (oldSettings.relativeRange) {
|
||||
if (oldSettings.start && oldSettings.end) {
|
||||
if (type === 'all') {
|
||||
rules.push({ type: 'geTodayPlusDays', column: oldRule.column, value: oldSettings.start});
|
||||
rules.push({ type: 'leTodayPlusDays', column: oldRule.column, value: oldSettings.end});
|
||||
rules.push({ type: 'geTodayPlusDays', column: oldRule.column, value: oldSettings.start });
|
||||
rules.push({ type: 'leTodayPlusDays', column: oldRule.column, value: oldSettings.end });
|
||||
} else {
|
||||
rules.push({
|
||||
type: 'all',
|
||||
rules: [
|
||||
{ type: 'geTodayPlusDays', column: oldRule.column, value: oldSettings.start},
|
||||
{ type: 'leTodayPlusDays', column: oldRule.column, value: oldSettings.end}
|
||||
{ type: 'geTodayPlusDays', column: oldRule.column, value: oldSettings.start },
|
||||
{ type: 'leTodayPlusDays', column: oldRule.column, value: oldSettings.end }
|
||||
]
|
||||
});
|
||||
}
|
||||
} else if (oldSettings.start) {
|
||||
rules.push({ type: 'geTodayPlusDays', column: oldRule.column, value: oldSettings.startDirection ? oldSettings.start : -oldSettings.start });
|
||||
rules.push({ type: 'geTodayPlusDays', column: oldRule.column, value: oldSettings.startDirection ? oldSettings.start : -oldSettings.start });
|
||||
}
|
||||
if (oldSettings.end) {
|
||||
rules.push({ type: 'leTodayPlusDays', column: oldRule.column, value: oldSettings.endDirection ? oldSettings.end : -oldSettings.end });
|
||||
rules.push({ type: 'leTodayPlusDays', column: oldRule.column, value: oldSettings.endDirection ? oldSettings.end : -oldSettings.end });
|
||||
}
|
||||
} else if (oldSettings.range) {
|
||||
if (oldSettings.start && oldSettings.end) {
|
||||
if (type === 'all') {
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start});
|
||||
rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end});
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start });
|
||||
rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end });
|
||||
} else {
|
||||
rules.push({
|
||||
type: 'all',
|
||||
rules: [
|
||||
{ type: 'ge', column: oldRule.column, value: oldSettings.start},
|
||||
{ type: 'le', column: oldRule.column, value: oldSettings.end}
|
||||
{ type: 'ge', column: oldRule.column, value: oldSettings.start },
|
||||
{ type: 'le', column: oldRule.column, value: oldSettings.end }
|
||||
]
|
||||
});
|
||||
}
|
||||
} else if (oldSettings.start) {
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start });
|
||||
rules.push({ type: 'ge', column: oldRule.column, value: oldSettings.start });
|
||||
}
|
||||
if (oldSettings.end) {
|
||||
rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end });
|
||||
rules.push({ type: 'le', column: oldRule.column, value: oldSettings.end });
|
||||
}
|
||||
} else {
|
||||
rules.push({ type: 'eq', column: oldRule.column, value: oldSettings.value });
|
||||
rules.push({ type: 'eq', column: oldRule.column, value: oldSettings.value });
|
||||
}
|
||||
break;
|
||||
case 'option':
|
||||
rules.push({ type: 'eq', column: oldRule.column, value: oldSettings.value });
|
||||
if (oldSettings.value) {
|
||||
rules.push({ type: 'isTrue', column: oldRule.column });
|
||||
} else {
|
||||
rules.push({ type: 'isFalse', column: oldRule.column });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown rule for column ${oldRule.column} with field type ${fieldType}`);
|
||||
|
@ -780,6 +797,51 @@ async function addFiles(knex) {
|
|||
}
|
||||
}
|
||||
|
||||
async function convertTemplateContent(type, html, data) {
|
||||
if (type == 'summernote') {
|
||||
type = 'ckeditor4';
|
||||
data.source = html;
|
||||
}
|
||||
|
||||
if (type == 'mosaico') {
|
||||
type = 'mosaicoWithFsTemplate';
|
||||
data.mosaicoFsTemplate = data.template;
|
||||
delete data.template;
|
||||
}
|
||||
|
||||
if (type == 'grapejs') {
|
||||
type = 'grapesjs';
|
||||
|
||||
if (data.mjml) {
|
||||
data.sourceType = 'mjml';
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const mjmlDoc = new DOMParser().parseFromString(data.mjml, 'text/xml');
|
||||
|
||||
const container = mjmlDoc.getElementsByTagName('mj-container')[0];
|
||||
data.source = mjContainer = container ? serializer.serializeToString(container) : '<mj-container></mj-container>';
|
||||
|
||||
data.style = '';
|
||||
delete data.mjml;
|
||||
|
||||
} else {
|
||||
data.sourceType = 'html';
|
||||
data.source = data.html || html || '';
|
||||
data.style = data.css;
|
||||
delete data.css;
|
||||
}
|
||||
|
||||
delete data.template;
|
||||
}
|
||||
|
||||
if (type == 'codeeditor') {
|
||||
data.sourceType = 'html';
|
||||
data.source = html || '';
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
async function migrateTemplates(knex) {
|
||||
await knex.schema.table('templates', table => {
|
||||
table.text('data', 'longtext');
|
||||
|
@ -789,20 +851,16 @@ async function migrateTemplates(knex) {
|
|||
const templates = await knex('templates');
|
||||
|
||||
for (const template of templates) {
|
||||
let type = template.editor_name;
|
||||
const data = JSON.parse(template.editor_data || '{}');
|
||||
|
||||
if (type == 'summernote') {
|
||||
type = 'ckeditor';
|
||||
}
|
||||
const type = await convertTemplateContent(template.editor_name, template.html, data);
|
||||
|
||||
if (type == 'mosaico') {
|
||||
type = 'mosaicoWithFsTemplate';
|
||||
data.mosaicoFsTemplate = data.template;
|
||||
delete data.template;
|
||||
}
|
||||
|
||||
await knex('templates').where('id', template.id).update({type, data: JSON.stringify(data)});
|
||||
await knex('templates').where('id', template.id).update({
|
||||
type,
|
||||
text: template.text || '',
|
||||
html: template.html || '',
|
||||
data: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
await knex.schema.table('templates', table => {
|
||||
|
@ -953,8 +1011,10 @@ async function migrateCampaigns(knex) {
|
|||
for (const campaign of campaigns) {
|
||||
const data = {};
|
||||
|
||||
await knex.raw('INSERT INTO `campaign_messages` (`id`, `campaign`, `list`, `subscription`, `send_configuration`, `status`, `response`, `response_id`, `updated`, `created`) ' +
|
||||
'SELECT `id`, ' + campaign.id + ', `list`, `subscription`, ' + getSystemSendConfigurationId() + ', `status`, `response`, `response_id`, `updated`, `created` FROM `campaign__' + campaign.id + '`;');
|
||||
// IGNORE is here because the original table had a key based also on segment. We droped the distinction based on segmention in mailtrain v2,
|
||||
// which means we can get some duplicates. Hopefully it's not such a big harm to ignore the duplicates.
|
||||
await knex.raw('INSERT IGNORE INTO `campaign_messages` (`campaign`, `list`, `subscription`, `send_configuration`, `status`, `response`, `response_id`, `updated`, `created`) ' +
|
||||
'SELECT ' + campaign.id + ', `list`, `subscription`, ' + getSystemSendConfigurationId() + ', `status`, `response`, `response_id`, `updated`, `created` FROM `campaign__' + campaign.id + '`;');
|
||||
|
||||
await knex.raw('INSERT INTO `campaign_links` (`campaign`, `list`, `subscription`, `link`, `ip`, `device_type`, `country`, `count`, `created`) ' +
|
||||
'SELECT ' + campaign.id + ', `list`, `subscriber`, `link`, `ip`, `device_type`, `country`, `count`, `created` FROM `campaign_tracker__' + campaign.id + '`;');
|
||||
|
@ -964,31 +1024,22 @@ async function migrateCampaigns(knex) {
|
|||
|
||||
if (campaign.type === CampaignType.REGULAR || campaign.type === CampaignType.RSS || campaign.type === CampaignType.RSS_ENTRY || campaign.type === CampaignType.TRIGGERED) {
|
||||
if (campaign.template) {
|
||||
let editorType = campaign.editor_name;
|
||||
const editorData = JSON.parse(campaign.editor_data || '{}');
|
||||
|
||||
if (editorType === 'summernote') {
|
||||
editorType = 'ckeditor';
|
||||
}
|
||||
|
||||
if (editorType === 'mosaico') {
|
||||
editorType = 'mosaicoWithFsTemplate';
|
||||
editorData.mosaicoFsTemplate = editorData.template;
|
||||
delete editorData.template;
|
||||
}
|
||||
const editorType = await convertTemplateContent(campaign.editor_name, campaign.html, editorData);
|
||||
|
||||
campaign.source = CampaignSource.CUSTOM_FROM_TEMPLATE;
|
||||
data.sourceCustom = {
|
||||
type: editorType,
|
||||
data: editorData,
|
||||
html: campaign.html,
|
||||
text: campaign.text,
|
||||
html: campaign.html_prepared || campaign.html || '',
|
||||
text: campaign.text || '',
|
||||
};
|
||||
|
||||
data.sourceTemplate = campaign.template;
|
||||
|
||||
// For source === CampaignSource.TEMPLATE, the data is as follows:
|
||||
// data.sourceTemplate = <template id>
|
||||
|
||||
} else {
|
||||
campaign.source = CampaignSource.URL;
|
||||
data.sourceUrl = campaign.source_url;
|
||||
|
@ -1155,28 +1206,58 @@ async function migrateImporter(knex) {
|
|||
|
||||
exports.up = (knex, Promise) => (async() => {
|
||||
await migrateBase(knex);
|
||||
log.verbose('Migration', 'Base complete')
|
||||
await addNamespaces(knex);
|
||||
log.verbose('Migration', 'Namespaces complete')
|
||||
|
||||
await migrateUsers(knex);
|
||||
log.verbose('Migration', 'Users complete')
|
||||
|
||||
await migrateCustomForms(knex);
|
||||
log.verbose('Migration', 'Custom forms complete')
|
||||
|
||||
await migrateCustomFields(knex);
|
||||
log.verbose('Migration', 'Custom fields complete')
|
||||
|
||||
await migrateSubscriptions(knex);
|
||||
|
||||
await migrateSegments(knex);
|
||||
log.verbose('Migration', 'Segments complete')
|
||||
|
||||
await migrateReports(knex);
|
||||
log.verbose('Migration', 'Reports complete')
|
||||
|
||||
await migrateSettings(knex);
|
||||
log.verbose('Migration', 'Settings complete')
|
||||
|
||||
await migrateTemplates(knex);
|
||||
log.verbose('Migration', 'Templates complete')
|
||||
|
||||
|
||||
await addMosaicoTemplates(knex);
|
||||
log.verbose('Migration', 'Mosaico templates complete')
|
||||
|
||||
await migrateCampaigns(knex);
|
||||
log.verbose('Migration', 'Campaigns complete')
|
||||
|
||||
|
||||
await addPermissions(knex);
|
||||
log.verbose('Migration', 'Permissions complete')
|
||||
|
||||
await addFiles(knex);
|
||||
log.verbose('Migration', 'Files complete')
|
||||
|
||||
|
||||
await migrateAttachments(knex);
|
||||
log.verbose('Migration', 'Attachments complete')
|
||||
|
||||
|
||||
await migrateTriggers(knex);
|
||||
log.verbose('Migration', 'Trigger complete')
|
||||
|
||||
|
||||
await migrateImporter(knex);
|
||||
log.verbose('Migration', 'Importer complete')
|
||||
})();
|
||||
|
||||
exports.down = (knex, Promise) => (async() => {
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -26,7 +26,7 @@ By default e2e tests use `phantomjs`. If you want to use a different browser you
|
|||
Then adjust your config:
|
||||
|
||||
```
|
||||
[seleniumwebdriver]
|
||||
[seleniumWebDriver]
|
||||
browser="firefox"
|
||||
```
|
||||
|
||||
|
|
|
@ -27,9 +27,9 @@ port=3000
|
|||
user="mailtrain_test"
|
||||
password="$MYSQL_PASSWORD"
|
||||
database="mailtrain_test"
|
||||
[testserver]
|
||||
[testServer]
|
||||
enabled=true
|
||||
[seleniumwebdriver]
|
||||
[seleniumWebDriver]
|
||||
browser="phantomjs"
|
||||
EOT
|
||||
|
||||
|
|
|
@ -4,8 +4,8 @@ const config = require('config');
|
|||
|
||||
module.exports = {
|
||||
app: config,
|
||||
baseUrl: 'http://localhost:' + config.www.port,
|
||||
mailUrl: 'http://localhost:' + config.testserver.mailboxserverport,
|
||||
baseUrl: 'http://localhost:' + config.www.publicPort,
|
||||
mailUrl: 'http://localhost:' + config.testServer.mailboxServerPort,
|
||||
users: {
|
||||
admin: {
|
||||
username: 'admin',
|
||||
|
@ -74,10 +74,10 @@ module.exports = {
|
|||
'service-url': 'http://localhost:' + config.www.publicPort + '/',
|
||||
'admin-email': 'keep.admin@mailtrain.org',
|
||||
'default-homepage': 'https://mailtrain.org',
|
||||
'smtp-hostname': config.testserver.host,
|
||||
'smtp-port': config.testserver.port,
|
||||
'smtp-hostname': config.testServer.host,
|
||||
'smtp-port': config.testServer.port,
|
||||
'smtp-encryption': 'NONE',
|
||||
'smtp-user': config.testserver.username,
|
||||
'smtp-pass': config.testserver.password
|
||||
'smtp-user': config.testServer.username,
|
||||
'smtp-pass': config.testServer.password
|
||||
}
|
||||
};
|
||||
|
|
|
@ -10,8 +10,8 @@ if (process.env.NODE_ENV !== 'test' || !fs.existsSync(path.join(__dirname, '..',
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
if (config.app.testserver.enabled !== true) {
|
||||
log.error('e2e', 'This script only runs if the testserver is enabled. Check config/test.toml');
|
||||
if (config.app.testServer.enabled !== true) {
|
||||
log.error('e2e', 'This script only runs if the testServer is enabled. Check config/test.toml');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ const config = require('./config');
|
|||
const webdriver = require('selenium-webdriver');
|
||||
|
||||
const driver = new webdriver.Builder()
|
||||
.forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs')
|
||||
.forBrowser(config.app.seleniumWebDriver.browser || 'phantomjs')
|
||||
.build();
|
||||
|
||||
const failHandlerRunning = new WorkerCounter();
|
||||
|
@ -96,7 +96,7 @@ function UseCaseReporter(runner) {
|
|||
const info = `URL: ${currentUrl}`;
|
||||
await fs.writeFile('last-failed-e2e-test.info', info);
|
||||
await fs.writeFile('last-failed-e2e-test.html', await driver.getPageSource());
|
||||
await fs.writeFile('last-failed-e2e-test.png', new Buffer(await driver.takeScreenshot(), 'base64'));
|
||||
await fs.writeFile('last-failed-e2e-test.png', Buffer.from(await driver.takeScreenshot(), 'base64'));
|
||||
failHandlerRunning.exit();
|
||||
})();
|
||||
|
||||
|
|
|
@ -104,7 +104,7 @@ module.exports = (...extras) => Object.assign({
|
|||
|
||||
async saveScreenshot(destPath) {
|
||||
const pngData = await driver.takeScreenshot();
|
||||
const buf = new Buffer(pngData, 'base64');
|
||||
const buf = Buffer.from(pngData, 'base64');
|
||||
await fs.writeFile(destPath, buf);
|
||||
},
|
||||
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
let nodemailer = require('nodemailer');
|
||||
|
||||
// This is a dummy test to ensure that nodeunit would not fail on 0 assertions
|
||||
module.exports['Load nodemailer'] = function (test) {
|
||||
let transport = nodemailer.createTransport({
|
||||
streamTransport: true
|
||||
});
|
||||
test.ok(transport);
|
||||
test.done();
|
||||
};
|
Loading…
Reference in a new issue