-
-
Notifications
You must be signed in to change notification settings - Fork 11.9k
Collect gift email delivery outcomes #29852
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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})); | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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, | ||
|
|
@@ -106,4 +116,31 @@ export const init = () => { | |
| }) | ||
| ) | ||
| }); | ||
|
|
||
| if (labs.isSet('giftSubCustomization')) { | ||
| gifts.init({ | ||
| event: StartGiftEmailAnalyticsJobEvent, | ||
| mailgunTags: ['gift-delivery'], | ||
|
Comment on lines
+121
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When gift mail is sent through Ghost's transactional 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 |
|---|---|---|
| @@ -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' | ||
| }); |
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
giftSubCustomizationis disabled during boot and later enabled through the Labs UI, this conditional permanently skipsgifts.init()for the process lifetime. Accepted deliveries then callscheduleRecurringGiftDeliveriesJob(true)and register the worker, but no subscriber exists forStartGiftEmailAnalyticsJobEvent, so every run is a no-op and delivery outcomes remainunknownuntil Ghost restarts. Initialize the wrapper unconditionally and gate only job scheduling/processing, or initialize it when the flag changes.Useful? React with 👍 / 👎.