Various fixes.

This commit is contained in:
Tomas Bures 2018-12-23 19:27:29 +00:00
parent dd9b8b464a
commit 83ce716d94
21 changed files with 99 additions and 114 deletions

View file

@ -109,12 +109,10 @@ export default class List extends Component {
link: `/campaigns/${data[0]}/status`
});
if (status === CampaignStatus.SENDING || status === CampaignStatus.PAUSED || status === CampaignStatus.FINISHED) {
actions.push({
label: <Icon icon="signal" title={t('statistics')}/>,
link: `/campaigns/${data[0]}/statistics`
});
}
actions.push({
label: <Icon icon="signal" title={t('statistics')}/>,
link: `/campaigns/${data[0]}/statistics`
});
}
if (perms.includes('edit')) {

View file

@ -35,7 +35,6 @@ export default class Statistics extends Component {
this.state = {
entity: props.entity,
statisticsOverview: props.statisticsOverview
};
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
@ -43,8 +42,7 @@ export default class Statistics extends Component {
}
static propTypes = {
entity: PropTypes.object,
statisticsOverview: PropTypes.object
entity: PropTypes.object
}
@withAsyncErrorHandler
@ -54,12 +52,8 @@ export default class Statistics extends Component {
resp = await axios.get(getUrl(`rest/campaigns-stats/${this.props.entity.id}`));
const entity = resp.data;
resp = await axios.get(getUrl(`rest/campaign-statistics/${this.props.entity.id}/overview`));
const statisticsOverview = resp.data;
this.setState({
entity,
statisticsOverview
entity
});
}
@ -85,11 +79,10 @@ export default class Statistics extends Component {
render() {
const t = this.props.t;
const entity = this.state.entity;
const stats = this.state.statisticsOverview;
const total = entity.subscriptionsToSend === undefined ? undefined : entity.subscriptionsToSend + entity.delivered;
const renderMetrics = (key, label, showZoomIn = true) => {
const val = stats[key]
const val = entity[key]
return (
<AlignedRow label={label}><span className={styles.statsMetrics}>{val}</span>{showZoomIn && <span className={styles.zoomIn}><Link to={`/campaigns/${entity.id}/statistics/${key}`}><Icon icon="zoom-in"/></Link></span>}</AlignedRow>
@ -97,13 +90,13 @@ export default class Statistics extends Component {
}
const renderMetricsWithProgress = (key, label, progressBarClass, showZoomIn = true) => {
const val = stats[key]
const val = entity[key]
if (!stats.total) {
if (!total) {
return renderMetrics(key, label);
}
const rate = Math.round(val / stats.total * 100);
const rate = Math.round(val / total * 100);
return (
<AlignedRow label={label}>
@ -112,9 +105,6 @@ export default class Statistics extends Component {
<div
className={`progress-bar progress-bar-${progressBarClass}`}
role="progressbar"
aria-valuenow={stats.bounced}
aria-valuemin="0"
aria-valuemax="100"
style={{minWidth: '6em', width: rate + '%'}}>
{val}&nbsp;({rate}%)
</div>

View file

@ -60,7 +60,7 @@ export default class StatisticsOpened extends Component {
async refreshEntity() {
let resp;
resp = await axios.get(getUrl(`rest/campaigns-stats/${this.props.entity.id}`));
resp = await axios.get(getUrl(`rest/campaigns-settings/${this.props.entity.id}`));
const entity = resp.data;
resp = await axios.get(getUrl(`rest/campaign-statistics/${this.props.entity.id}/opened`));
@ -132,7 +132,7 @@ export default class StatisticsOpened extends Component {
<h4 className={styles.chartTitle}>{t('Distribution by device type')}</h4>
<Chart
width="100%"
height="300px"
height="380px"
chartType="PieChart"
loader={<div>{t('Loading chart')}</div>}
data={[
@ -144,7 +144,7 @@ export default class StatisticsOpened extends Component {
left: "25%",
top: 15,
width: "100%",
height: 270
height: 350
},
tooltip: {
showColorCode: true
@ -169,7 +169,7 @@ export default class StatisticsOpened extends Component {
<div className={`col-md-6 ${styles.chart}`}>
<Chart
width="100%"
height="300px"
height="380px"
chartType="PieChart"
loader={<div>{t('Loading chart')}</div>}
data={[
@ -181,7 +181,7 @@ export default class StatisticsOpened extends Component {
left: "25%",
top: 15,
width: "100%",
height: 270
height: 350
},
tooltip: {
showColorCode: true
@ -199,7 +199,7 @@ export default class StatisticsOpened extends Component {
<div className={`col-md-6 ${styles.chart}`}>
<Chart
width="100%"
height="300px"
height="380px"
chartType="GeoChart"
data={[
['Country', 'Count'],

View file

@ -273,7 +273,7 @@ class SendControls extends Component {
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
const subscrInfo = entity.subscriptionsToSend === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return (
<div>{yesNoDialog}
@ -316,7 +316,7 @@ class SendControls extends Component {
);
} else if (entity.status === CampaignStatus.FINISHED) {
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
const subscrInfo = entity.subscriptionsToSend === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return (
<div>{yesNoDialog}
@ -479,7 +479,7 @@ export default class Status extends Component {
<Title>{t('campaignStatus')}</Title>
<AlignedRow label={t('name')}>{entity.name}</AlignedRow>
<AlignedRow label={t('subscribers')}>{entity.subscriptionsTotal === undefined ? t('computing') : entity.subscriptionsTotal}</AlignedRow>
<AlignedRow label={t('Delivered')}>{entity.delivered}</AlignedRow>
<AlignedRow label={t('status')}>{this.campaignStatusLabels[entity.status]}</AlignedRow>
{sendSettings}

View file

@ -62,12 +62,9 @@ function getMenus(t) {
},
statistics: {
title: t('statistics'),
resolve: {
statisticsOverview: params => `rest/campaign-statistics/${params.campaignId}/overview`
},
link: params => `/campaigns/${params.campaignId}/statistics`,
visible: resolved => resolved.campaign.permissions.includes('viewStats') && (resolved.campaign.status === CampaignStatus.SENDING || resolved.campaign.status === CampaignStatus.PAUSED || resolved.campaign.status === CampaignStatus.FINISHED),
panelRender: props => <Statistics entity={props.resolved.campaign} statisticsOverview={props.resolved.statisticsOverview} />,
visible: resolved => resolved.campaign.permissions.includes('viewStats'),
panelRender: props => <Statistics entity={props.resolved.campaign} />,
children: {
delivered: {
title: t('Delivered'),

View file

@ -201,7 +201,7 @@ export class UntrustedContentRoot extends Component {
this.clientHeight = newHeight;
this.sendMessage('clientHeight', newHeight);
}
//this.periodicTimeoutId = setTimeout(this.periodicTimeoutHandler, 250);
this.periodicTimeoutId = setTimeout(this.periodicTimeoutHandler, 250);
}

View file

@ -216,7 +216,7 @@ export default class CUD extends Component {
<TextArea id="description" label={t('description')}/>
<InputField id="contact_email" label={t('contactEmail')} help={t('contactEmailUsedInSubscriptionFormsAnd')}/>
<InputField id="contact_email" label={t('contactEmail')} help={t('Contact email shown in the list subscription and management forms. If no contact email is given, the admin email from Global settings is used.')}/>
<InputField id="homepage" label={t('homepage')} help={t('homepageUrlUsedInSubscriptionFormsAnd')}/>
<InputField id="to_name" label={t('recipientsNameTemplate')} help={t('specifyUsingMergeTagsOfThisListHowTo')}/>
<TableSelect id="send_configuration" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} help={t('sendConfigurationThatWillBeUsedFor')}/>

View file

@ -90,7 +90,7 @@ export default class CUD extends Component {
' Email\n' +
' </th>\n' +
' <th>\n' +
' Tracker Count\n' +
' Open Count\n' +
' </th>\n' +
' </thead>\n' +
' {{#if results}}\n' +

View file

@ -271,7 +271,7 @@ export function getMailerTypes(t) {
beforeSaveGenericSMTP(data, zoneMtaType === ZoneMTAType.BUILTIN);
data.mailer_settings.zoneMtaType = zoneMtaType;
if (zoneMtaType === ZoneMTAType.WITH_HTTP_CONF || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF) {
if (zoneMtaType === ZoneMTAType.BUILTIN || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF) {
data.mailer_settings.dkimDomain = data.dkimDomain;
data.mailer_settings.dkimSelector = data.dkimSelector;
data.mailer_settings.dkimPrivateKey = data.dkimPrivateKey;

View file

@ -10,9 +10,9 @@ const crypto = require('crypto');
let zoneMtaProcess;
const zoneMtaDir = path.join(__dirname, '..', '..', 'zone-mta');
const zoneMtaBuiltingConfig = path.join(zoneMtaDir, 'config', 'builtin-zonemta.json')
const zoneMtaBuiltingConfig = path.join(zoneMtaDir, 'config', 'builtin-zonemta.json');
const password = crypto.randomBytes(20).toString('hex').toLowerCase();
const password = process.env.BUILTIN_ZONE_MTA_PASSWORD || crypto.randomBytes(20).toString('hex').toLowerCase();
function getUsername() {
return 'mailtrain';

View file

@ -95,11 +95,18 @@ async function _sendMail(transport, mail, template) {
}
async function _sendTransactionalMail(transport, mail, template) {
const sendConfiguration = transport.mailer.sendConfiguration;
if (!mail.headers) {
mail.headers = {};
}
mail.headers['X-Sending-Zone'] = 'transactional';
mail.from = {
name: sendConfiguration.from_name,
address: sendConfiguration.from_email
};
const htmlRenderer = await tools.getTemplate(template.html, template.locale);
if (htmlRenderer) {

View file

@ -5,6 +5,7 @@ const log = require('./log');
const path = require('path');
const knex = require('./knex');
const {CampaignStatus} = require('../../shared/campaigns');
const builtinZoneMta = require('./builtin-zone-mta');
let messageTid = 0;
let senderProcess;
@ -16,7 +17,10 @@ function spawn(callback) {
.then(() => {
senderProcess = fork(path.join(__dirname, '..', 'services', 'sender-master.js'), [], {
cwd: path.join(__dirname, '..'),
env: {NODE_ENV: process.env.NODE_ENV}
env: {
NODE_ENV: process.env.NODE_ENV,
BUILTIN_ZONE_MTA_PASSWORD: builtinZoneMta.getPassword()
}
});
senderProcess.on('message', msg => {

View file

@ -110,7 +110,7 @@ async function _sendMail(list, email, template, locale, subjectKey, relativeUrls
const data = {
title: list.name,
homepage: configItems.defaultHomepage || getTrustedUrl(),
contactAddress: list.from_email || configItems.adminEmail,
contactAddress: list.contact_email || configItems.adminEmail,
};
for (let relativeUrlKey in relativeUrls) {
@ -140,10 +140,6 @@ async function _sendMail(list, email, template, locale, subjectKey, relativeUrls
if (list.send_configuration) {
const mailer = await mailers.getOrCreateMailer(list.send_configuration);
await mailer.sendTransactionalMail({
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress
},
to: {
name: getDisplayName(flds, subscription),
address: email

View file

@ -324,6 +324,7 @@ async function rawGetByTx(tx, key, id) {
'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.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`)
])
.first();
@ -361,18 +362,12 @@ async function getByIdTx(tx, context, id, withPermissions = true, content = Cont
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
const unsentQryGen = await getSubscribersQueryGeneratorTx(tx, id, true);
const unsentQryGen = await getSubscribersQueryGeneratorTx(tx, id);
if (unsentQryGen) {
const res = await unsentQryGen(tx).count('* AS subscriptionsToSend').first();
entity.subscriptionsToSend = res.subscriptionsToSend;
}
const totalQryGen = await getSubscribersQueryGeneratorTx(tx, id, false);
if (totalQryGen) {
const res = await totalQryGen(tx).count('* AS subscriptionsTotal').first();
entity.subscriptionsTotal = res.subscriptionsTotal;
}
} else if (content === Content.WITHOUT_SOURCE_CUSTOM) {
delete entity.data.sourceCustom;
@ -765,7 +760,7 @@ async function updateMessageResponse(context, message, response, responseId) {
});
}
async function getSubscribersQueryGeneratorTx(tx, campaignId, onlyUnsent) {
async function getSubscribersQueryGeneratorTx(tx, campaignId) {
/*
This is supposed to produce queries like this:
@ -812,7 +807,7 @@ async function getSubscribersQueryGeneratorTx(tx, campaignId, onlyUnsent) {
if (subsQrys.length > 0) {
let subsQry;
const unsentWhere = onlyUnsent ? ' where `sent` = false' : '';
const unsentWhere = ' where `sent` = false';
if (subsQrys.length === 1) {
const subsUnionSql = '(select `email`, `campaign_list_id`, `sent` from (' + subsQrys[0].sql + ') as `pending_subscriptions_all`' + unsentWhere + ') as `pending_subscriptions`'
@ -905,30 +900,12 @@ async function disable(context, campaignId) {
}
async function getStatisticsOverview(context, id) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
const stats = await tx('campaigns').where('id', id).select(['delivered', 'unsubscribed', 'bounced', 'complained', 'blacklisted', 'opened', 'clicks']).first();
const totalQryGen = await getSubscribersQueryGeneratorTx(tx, id, false);
if (totalQryGen) {
const res = await totalQryGen(tx).count('* AS subscriptionsTotal').first();
stats.total = res.subscriptionsTotal;
} else {
stats.total = 0;
}
return stats;
});
}
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).groupBy('device_type').select('device_type AS key').count('* as count');
const countries = await tx('campaign_links').where('campaign', id).groupBy('country').select('country AS key').count('* as count');
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');
return {
devices,
@ -976,5 +953,4 @@ module.exports.disable = disable;
module.exports.rawGetByTx = rawGetByTx;
module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
module.exports.getStatisticsOverview = getStatisticsOverview;
module.exports.getStatisticsOpened = getStatisticsOpened;

View file

@ -32,7 +32,10 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
const subscription = await subscriptions.getByCidTx(tx, contextHelpers.getAdminContext(), list.id, subscriptionCid);
const country = geoip.lookupCountry(remoteIp) || null;
const device = uaParser(userAgent, { unknownUserAgentDeviceType: 'desktop', emptyUserAgentDeviceType: 'desktop' });
const device = uaParser(userAgent, {
unknownUserAgentDeviceType: 'desktop',
emptyUserAgentDeviceType: 'desktop'
});
const now = new Date();
const _countLink = async (clickLinkId, incrementOnDup) => {
@ -66,7 +69,6 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
};
// Update opened and click timestamps
const latestUpdates = {};
@ -82,7 +84,6 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
await tx(subscriptions.getSubscriptionTableName(list.id)).update(latestUpdates).where('id', subscription.id);
}
// Update clicks
if (linkId > LinkId.GENERAL_CLICK && !campaign.click_tracking_disabled) {
await tx('links').increment('hits').where('id', linkId);
@ -125,6 +126,8 @@ async function addOrGet(campaignId, url) {
id: ids[0],
cid
};
} else {
return link;
}
});
}

View file

@ -255,31 +255,24 @@ async function _getCampaignStatistics(campaign, select, unionQryFn, listQryFn, a
} else {
return knex.raw(subsSql, subsBindings);
}
}
};
if (subsQrys.length === 1) {
subsSql = subsQrys[0].sql;
subsBindings = subsQrys[0].bindings;
if (asStream) {
return await applyUnionQryFn(subsSql, subsBindings).stream();
} else {
return await applyUnionQryFn(subsSql, subsBindings);
}
} else {
subsSql = subsQrys.map(qry => '(' + qry.sql + ')').join(' UNION ALL ');
subsBindings = Array.prototype.concat(...subsQrys.map(qry => qry.bindings));
}
if (asStream) {
return applyUnionQryFn(subsSql, subsBindings).stream();
if (asStream) {
return applyUnionQryFn(subsSql, subsBindings).stream();
} else {
const res = await applyUnionQryFn(subsSql, subsBindings);
if (res[0] && Array.isArray(res[0])) {
return res[0]; // UNION ALL generates an array with result and schema
} else {
const res = await applyUnionQryFn(subsSql, subsBindings);
if (res[0] && Array.isArray(res[0])) {
return res[0]; // UNION ALL generates an array with result and schema
} else {
return res;
}
return res;
}
}

View file

@ -304,9 +304,6 @@ async function sendPasswordReset(locale, usernameOrEmail) {
const mailer = await mailers.getOrCreateMailer();
await mailer.sendTransactionalMail({
from: {
address: adminEmail
},
to: {
address: user.email
},

View file

@ -13,9 +13,10 @@ router.getAsync('/:campaign/:list/:subscription/:link', async (req, res) => {
if (link) {
// In Mailtrain v1 we would do the URL expansion here based on merge tags. We don't do it here anymore. Instead, the URLs are expanded when message is sent out (in links.updateLinks)
res.redirect(link.url);
res.redirect(302, link.url);
await links.countLink(req.ip, req.headers['user-agent'], req.params.campaign, req.params.list, req.params.subscription, link.id);
} else {
log.error('Redirect', 'Unresolved URL: <%s>', req.url);
throw new interoperableErrors.NotFoundError('Oops, we couldn\'t find a link for the URL you clicked');

View file

@ -94,10 +94,6 @@ router.postAsync('/campaign-disable/:campaignId', passport.loggedIn, passport.cs
return res.json(await campaigns.disable(req.context, castToInteger(req.params.campaignId), null));
});
router.getAsync('/campaign-statistics/:campaignId/overview', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.getStatisticsOverview(req.context, castToInteger(req.params.campaignId)));
});
router.getAsync('/campaign-statistics/:campaignId/opened', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.getStatisticsOpened(req.context, castToInteger(req.params.campaignId)));
});

View file

@ -670,8 +670,8 @@ async function webNotice(type, req, res) {
const data = {
title: list.name,
homepage: configItems.defaultHomepage || getTrustedUrl(),
contactAddress: list.from_email || configItems.adminEmail,
homepage: list.homepage || configItems.defaultHomepage || getTrustedUrl(),
contactAddress: list.contact_email || configItems.adminEmail,
template: {
template: 'subscription/web-' + type + '-notice.mjml.hbs',
layout: 'subscription/layout.mjml.hbs',

View file

@ -8,6 +8,7 @@ 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');
let messageTid = 0;
const workerProcesses = new Map();
@ -24,6 +25,7 @@ const workerBatchSize = 100;
const messageQueue = new Map(); // campaignId -> [{listId, email}]
const messageQueueCont = new Map(); // campaignId -> next batch callback
const campaignFinishCont = new Map(); // campaignId -> worker finished callback
const workAssignment = new Map(); // workerId -> { campaignId, subscribers: [{listId, email}] }
@ -32,6 +34,8 @@ let queuedLastId = 0;
function messagesProcessed(workerId) {
const wa = workAssignment.get(workerId);
workAssignment.delete(workerId);
idleWorkers.push(workerId);
@ -40,6 +44,11 @@ function messagesProcessed(workerId) {
setImmediate(workerSchedulerCont);
workerSchedulerCont = null;
}
if (campaignFinishCont.has(wa.campaignId)) {
setImmediate(campaignFinishCont.get(wa.campaignId));
campaignFinishCont.delete(wa.campaignId);
}
}
async function scheduleWorkers() {
@ -78,8 +87,8 @@ async function scheduleWorkers() {
workAssignment.set(workerId, {campaignId, subscribers});
if (queue.length === 0 && messageQueueCont.has(campaignId)) {
const scheduleMessages = messageQueueCont.get(campaignId);
setImmediate(scheduleMessages);
setImmediate(messageQueueCont.get(campaignId));
messageQueueCont.delete(campaignId);
}
sendToWorker(workerId, 'process-messages', {
@ -99,9 +108,24 @@ async function scheduleWorkers() {
}
async function processCampaign(campaignId) {
async function finish() {
let workerRunning = false;
for (const wa of workAssignment.values()) {
if (wa.campaignId === campaignId) {
workerRunning = true;
}
}
if (workerRunning) {
const workerFinished = new Promise(resolve => {
campaignFinishCont.set(campaignId, resolve);
});
await workerFinished;
setImmediate(finish);
}
await knex('campaigns').where('id', campaignId).update({status: CampaignStatus.FINISHED});
messageQueue.delete(campaignId);
}
@ -120,7 +144,7 @@ async function processCampaign(campaignId) {
let qryGen;
await knex.transaction(async tx => {
qryGen = await campaigns.getSubscribersQueryGeneratorTx(tx, campaignId, true);
qryGen = await campaigns.getSubscribersQueryGeneratorTx(tx, campaignId);
});
if (qryGen) {
@ -262,7 +286,10 @@ async function spawnWorker(workerId) {
const senderProcess = fork(path.join(__dirname, 'sender-worker.js'), [workerId], {
cwd: path.join(__dirname, '..'),
env: {NODE_ENV: process.env.NODE_ENV}
env: {
NODE_ENV: process.env.NODE_ENV,
BUILTIN_ZONE_MTA_PASSWORD: builtinZoneMta.getPassword()
}
});
senderProcess.on('message', msg => {