Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions ghost/core/core/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,17 @@ async function initBackgroundServices({config}) {
// Load email analytics recurring jobs
if (config.get('backgroundJobs:emailAnalytics')) {
const emailAnalyticsJobs = require('./server/services/email-analytics/jobs');
await Promise.all([
const analyticsJobs = [
emailAnalyticsJobs.scheduleRecurringNewslettersJob(),
emailAnalyticsJobs.scheduleRecurringAutomationsJob()
]);
];

const labs = require('./shared/labs');
if (labs.isSet('giftSubCustomization')) {
analyticsJobs.push(emailAnalyticsJobs.scheduleRecurringGiftDeliveriesJob());
}

await Promise.all(analyticsJobs);
}

const updateCheck = require('./server/services/update-check');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export class StartGiftEmailAnalyticsJobEvent {
readonly timestamp: Date;

constructor(timestamp: Date) {
this.timestamp = timestamp;
}

static create(timestamp = new Date()) {
return new StartGiftEmailAnalyticsJobEvent(timestamp);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type {BatchEventProcessor} from './batch-event-processor';
import {EventProcessingResult} from './event-processing-result';

type GiftService = {
recordDeliveryOutcome(data: {
providerMessageId: string;
outcome: 'delivered' | 'temporary_failed' | 'permanent_failed';
timestamp: Date;
error: string | null;
}): Promise<boolean>;
};

type EmailAnalyticsEvent = {
type: string;
severity?: string;
providerId: string;
timestamp: Date;
error?: {code?: unknown; message?: unknown; enhancedCode?: unknown} | null;
};

const normalizeMessageId = (value: string): string => value.trim().replace(/^<|>$/g, '');

export class GiftEmailAnalyticsBatchProcessor implements BatchEventProcessor {
readonly #giftService: GiftService;

constructor({giftService}: {giftService: GiftService}) {
this.#giftService = giftService;
}

async processBatch(events: ReadonlyArray<EmailAnalyticsEvent>, result: EventProcessingResult, fetchData: {lastEventTimestamp?: Date}): Promise<void> {
for (const event of events) {
if (!fetchData.lastEventTimestamp || event.timestamp > fetchData.lastEventTimestamp) {
fetchData.lastEventTimestamp = event.timestamp;
}

let outcome: 'delivered' | 'temporary_failed' | 'permanent_failed' | null = null;
if (event.type === 'delivered') {
outcome = 'delivered';
} else if (event.type === 'failed') {
outcome = event.severity === 'temporary' ? 'temporary_failed' : 'permanent_failed';
}

if (!outcome) {
result.merge(new EventProcessingResult({unhandled: 1}));
continue;
}

const updated = await this.#giftService.recordDeliveryOutcome({
providerMessageId: normalizeMessageId(event.providerId),
outcome,
timestamp: event.timestamp,
error: event.error ? JSON.stringify(event.error) : null
});

if (!updated) {
result.merge(new EventProcessingResult({unprocessable: 1}));
} else if (outcome === 'delivered') {
result.merge(new EventProcessingResult({delivered: 1}));
} else if (outcome === 'temporary_failed') {
result.merge(new EventProcessingResult({temporaryFailed: 1}));
} else {
result.merge(new EventProcessingResult({permanentFailed: 1}));
}
}
}
}
37 changes: 37 additions & 0 deletions ghost/core/core/server/services/email-analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import {StartAutomationEmailAnalyticsJobEvent} from './events/start-automation-e
import {AUTOMATION_EMAIL_TAG} from '../member-welcome-emails/constants';
import * as automationsApi from '../automations/automations-api';
import {AutomationEmailAnalyticsBatchProcessor} from './automation-email-analytics-batch-processor';
import {GiftEmailAnalyticsBatchProcessor} from './gift-email-analytics-batch-processor';
import {StartGiftEmailAnalyticsJobEvent} from './events/start-gift-email-analytics-job-event';
// @ts-expect-error This CommonJS service wrapper lacks type declarations.
import giftsService from '../gifts';
// @ts-expect-error This CommonJS service lacks type declarations.
import labs from '../../../shared/labs';

export const newsletters = new EmailAnalyticsServiceWrapper({
logName: 'newsletters'
Expand All @@ -33,6 +39,10 @@ export const automations = new EmailAnalyticsServiceWrapper({
logName: 'automations',
});

export const gifts = new EmailAnalyticsServiceWrapper({
logName: 'gifts'
});

export const init = () => {
const newsletterEmailEventProcessor = new EmailEventProcessor({
domainEvents,
Expand Down Expand Up @@ -106,4 +116,31 @@ export const init = () => {
})
)
});

if (labs.isSet('giftSubCustomization')) {
gifts.init({
Comment on lines +120 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Initialize gift analytics before runtime flag changes

When giftSubCustomization is disabled during boot and later enabled through the Labs UI, this conditional permanently skips gifts.init() for the process lifetime. Accepted deliveries then call scheduleRecurringGiftDeliveriesJob(true) and register the worker, but no subscriber exists for StartGiftEmailAnalyticsJobEvent, so every run is a no-op and delivery outcomes remain unknown until Ghost restarts. Initialize the wrapper unconditionally and gate only job scheduling/processing, or initialize it when the flag changes.

Useful? React with 👍 / 👎.

event: StartGiftEmailAnalyticsJobEvent,
mailgunTags: ['gift-delivery'],
Comment on lines +121 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fetch from the transactional Mailgun account

When gift mail is sent through Ghost's transactional mail.transport=Mailgun configuration but newsletter Mailgun is absent or uses a different account/domain, this wrapper cannot find the resulting events. GiftEmailService sends with GhostMailer, which reads config.mail.options, while EmailAnalyticsServiceWrapper constructs MailgunClient, whose only credential sources are bulkEmail.mailgun and the mailgun_* newsletter settings. The recurring job therefore either reports Mailgun as unconfigured or searches the wrong domain, leaving every accepted gift's outcome unknown; the gift collector needs to use the same Mailgun credentials and domain as the transport that produced its message ID.

Useful? React with 👍 / 👎.

jobNames: {
latestNonOpened: 'email-analytics-gifts-latest-others',
missing: 'email-analytics-gifts-missing',
latestOpened: 'email-analytics-gifts-latest-opened',
scheduled: 'email-analytics-gifts-scheduled'
},
cursorSeed: {
tableName: 'gift_deliveries',
eventColumns: {
delivered: 'outcome_at',
failed: 'outcome_at'
}
},
createEventProcessor: () => (
new GiftEmailAnalyticsBatchProcessor({
giftService: {
recordDeliveryOutcome: data => giftsService.service.recordDeliveryOutcome(data)
}
})
)
});
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ type Models = {
AutomatedEmailRecipient: {
query(): ExistingRecipientQuery;
};
GiftDelivery?: {
query(): ExistingRecipientQuery;
};
};
type Config = {get(key: string): unknown};
type JobManager = {
Expand All @@ -39,6 +42,7 @@ function randomFiveMinuteCron(): string {
export class EmailAnalyticsJobScheduler {
#hasScheduledNewslettersJob = false;
#hasScheduledAutomationsJob = false;
#hasScheduledGiftDeliveriesJob = false;
readonly #models: Models;
readonly #config: Config;
readonly #jobManager: JobManager;
Expand Down Expand Up @@ -123,4 +127,29 @@ export class EmailAnalyticsJobScheduler {

this.#hasScheduledAutomationsJob = true;
}

async scheduleRecurringGiftDeliveriesJob(skipGiftDeliveryCheck: boolean = false): Promise<void> {
if (this.#hasScheduledGiftDeliveriesJob || !this.#isConfigured()) {
return;
}

const hasGiftDelivery = skipGiftDeliveryCheck || Boolean(
this.#models.GiftDelivery && await this.#models.GiftDelivery
.query()
.where('email_sent_at', '>', moment.utc().subtract(30, 'days').toDate())
.whereNotNull('email_provider_message_id')
.first('id')
);

if (!hasGiftDelivery || this.#hasScheduledGiftDeliveriesJob) {
return;
}

this.#jobManager.addJob({
at: randomFiveMinuteCron(),
job: path.resolve(__dirname, 'gift-fetch-latest/index.js'),
name: 'email-analytics-gift-fetch-latest'
});
this.#hasScheduledGiftDeliveriesJob = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const {run} = require('../fetch-latest-job');
const {StartGiftEmailAnalyticsJobEvent} = require('../../events/start-gift-email-analytics-job-event');

run({
event: StartGiftEmailAnalyticsJobEvent,
logName: 'gifts'
});
10 changes: 10 additions & 0 deletions ghost/core/core/server/services/email-analytics/jobs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,13 @@ exports.scheduleRecurringAutomationsJob = async (...args) => {
await emailAnalyticsJobScheduler.scheduleRecurringAutomationsJob(...args);
}
};

/**
* @param {Parameters<typeof EmailAnalyticsJobScheduler.prototype.scheduleRecurringGiftDeliveriesJob>} args
* @returns {Promise<void>}
*/
exports.scheduleRecurringGiftDeliveriesJob = async (...args) => {
if (!process.env.NODE_ENV.startsWith('test')) {
await emailAnalyticsJobScheduler.scheduleRecurringGiftDeliveriesJob(...args);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class GiftServiceWrapper {
const StartGiftDeliveryFlushEvent = require('./events/start-gift-delivery-flush-event');
const StartGiftCleanupEvent = require('./events/start-gift-cleanup-event');
const jobs = require('./jobs');
const emailAnalyticsJobs = require('../email-analytics/jobs');

const {GhostMailer} = require('../mail');
const settingsCache = require('../../../shared/settings-cache');
Expand Down Expand Up @@ -101,7 +102,7 @@ class GiftServiceWrapper {
giftReminderScheduler,
giftDeliveryScheduler,
giftEmailAnalytics: {
schedule: () => Promise.resolve()
schedule: () => emailAnalyticsJobs.scheduleRecurringGiftDeliveriesJob(true)
},
checkoutAdapter,
labsService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,27 @@ function buildScheduler({
emailAnalyticsEnabled = true,
backgroundJobEnabled = true,
emailCount = 1,
automatedEmailRecipient = null
automatedEmailRecipient = null,
giftDelivery = null
}: {
emailAnalyticsEnabled?: boolean;
backgroundJobEnabled?: boolean;
emailCount?: string | number;
automatedEmailRecipient?: unknown;
giftDelivery?: unknown;
} = {}) {
const newsletterQuery = buildNewsletterQuery(emailCount);
const automationsQuery = buildAutomationsQuery(automatedEmailRecipient);
const giftQuery = buildAutomationsQuery(giftDelivery);
const models = {
Email: {
where: newsletterQuery.where
},
AutomatedEmailRecipient: {
query: sinon.stub().returns(automationsQuery)
},
GiftDelivery: {
query: sinon.stub().returns(giftQuery)
}
};
const config = {
Expand All @@ -65,6 +71,7 @@ function buildScheduler({
jobManager,
newsletterQuery,
automationsQuery,
giftQuery,
models
};
}
Expand Down Expand Up @@ -281,4 +288,24 @@ describe('EmailAnalyticsJobScheduler', function () {
sinon.assert.notCalled(jobManager.addJob);
sinon.assert.calledOnce(automationsQuery.first);
});

it('adds the existing recurring analytics collector for accepted gift email telemetry', async function () {
const {scheduler, jobManager, giftQuery, models} = buildScheduler({
emailCount: 0,
giftDelivery: {id: 'gift-id'}
});

await scheduler.scheduleRecurringGiftDeliveriesJob();

sinon.assert.calledOnceWithMatch(jobManager.addJob, {
job: sinon.match((value: unknown) => (
typeof value === 'string' && value.endsWith('gift-fetch-latest/index.js')
)),
name: 'email-analytics-gift-fetch-latest'
});
sinon.assert.calledOnce(models.GiftDelivery.query);
sinon.assert.calledOnceWithExactly(giftQuery.where, 'email_sent_at', '>', sinon.match.date);
sinon.assert.calledOnceWithExactly(giftQuery.whereNotNull, 'email_provider_message_id');
sinon.assert.calledOnceWithExactly(giftQuery.first, 'id');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import sinon from 'sinon';
import {GiftEmailAnalyticsBatchProcessor} from '../../../../../core/server/services/email-analytics/gift-email-analytics-batch-processor';
import {EventProcessingResult} from '../../../../../core/server/services/email-analytics/event-processing-result';

describe('GiftEmailAnalyticsBatchProcessor', function () {
it('maps Mailgun delivery and failure events to latest gift outcomes without opens', async function () {
const giftService = {recordDeliveryOutcome: sinon.stub().resolves(true)};
const processor = new GiftEmailAnalyticsBatchProcessor({giftService});
const result = new EventProcessingResult();
const fetchData: {lastEventTimestamp?: Date} = {};
const deliveredAt = new Date('2026-08-05T12:00:00.000Z');
const failedAt = new Date('2026-08-05T12:10:00.000Z');

await processor.processBatch([
{type: 'delivered', providerId: '<provider-123>', timestamp: deliveredAt},
{type: 'failed', severity: 'temporary', providerId: 'provider-123', timestamp: failedAt, error: {code: 421, message: 'try later'}},
{type: 'opened', providerId: 'provider-123', timestamp: new Date('2026-08-05T12:20:00.000Z')}
], result, fetchData);

sinon.assert.calledWithExactly(giftService.recordDeliveryOutcome, sinon.match({
providerMessageId: 'provider-123',
outcome: 'delivered',
timestamp: deliveredAt,
error: null
}));
assert.deepEqual(giftService.recordDeliveryOutcome.secondCall.firstArg, {
providerMessageId: 'provider-123',
outcome: 'temporary_failed',
timestamp: failedAt,
error: JSON.stringify({code: 421, message: 'try later'})
});
assert.equal(result.delivered, 1);
assert.equal(result.temporaryFailed, 1);
assert.equal(result.unhandled, 1);
});

it('marks events for unknown message IDs unprocessable', async function () {
const giftService = {recordDeliveryOutcome: sinon.stub().resolves(false)};
const processor = new GiftEmailAnalyticsBatchProcessor({giftService});
const result = new EventProcessingResult();

await processor.processBatch([
{type: 'failed', severity: 'permanent', providerId: 'unknown', timestamp: new Date()}
], result, {});

assert.equal(result.unprocessable, 1);
assert.equal(result.permanentFailed, 0);
});
});
Loading