ghost 6.8.1 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/components/tryghost-i18n-6.9.0.tgz +0 -0
  2. package/core/built/admin/assets/activitypub/activitypub.js +2 -2
  3. package/core/built/admin/assets/activitypub/{index-BbINZU9U.mjs → index-B29oZuTp.mjs} +2 -2
  4. package/core/built/admin/assets/activitypub/{index-C3M839De.mjs → index-C19nEXqT.mjs} +7998 -7923
  5. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-Dh1am4P6.mjs → CodeEditorView-BNKxdfRt.mjs} +2 -2
  6. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  7. package/core/built/admin/assets/admin-x-settings/{index-DqbTDHzA.mjs → index-B-_a183c.mjs} +2 -2
  8. package/core/built/admin/assets/admin-x-settings/{index-Hwd1HJ0-.mjs → index-CjRGpMVv.mjs} +2 -2
  9. package/core/built/admin/assets/admin-x-settings/{index-SxxA3jSX.mjs → index-Q0XmL0KU.mjs} +5 -5
  10. package/core/built/admin/assets/admin-x-settings/{modals-yhgQVHax.mjs → modals-omgXN6i-.mjs} +31 -27
  11. package/core/built/admin/assets/{chunk.524.bc2b771f38286b4a51ed.js → chunk.524.774a2df444e2ffde4942.js} +7 -7
  12. package/core/built/admin/assets/{chunk.582.a28f953895ae17fb3b24.js → chunk.582.ca4f05f3c39fda05b54c.js} +9 -9
  13. package/core/built/admin/assets/{ghost-7556359b8bd4ec08a6c23890b04bb56e.js → ghost-94d0fbb20e8e880fa9ba144cf26ab050.js} +27 -27
  14. package/core/built/admin/assets/ghost-dark-6c9cfa9c364e28c57e5983f68ec6f2fc.css +1 -0
  15. package/core/built/admin/assets/ghost-f724c1d53f5402f78a2d8cf8beb7c716.css +1 -0
  16. package/core/built/admin/assets/posts/posts.js +1 -1
  17. package/core/built/admin/assets/stats/stats.js +1 -1
  18. package/core/built/admin/index.html +4 -4
  19. package/core/frontend/public/robots.txt +1 -0
  20. package/core/frontend/web/middleware/index.js +0 -1
  21. package/core/frontend/web/routers/serve-favicon.js +56 -0
  22. package/core/frontend/web/site.js +2 -1
  23. package/core/server/data/tinybird/endpoints/api_kpis.pipe +0 -3
  24. package/core/server/data/tinybird/endpoints/api_top_locations.pipe +0 -3
  25. package/core/server/data/tinybird/endpoints/api_top_pages.pipe +0 -5
  26. package/core/server/data/tinybird/pipes/filtered_sessions.pipe +0 -3
  27. package/core/server/data/tinybird/scripts/analytics-generator.js +106 -2
  28. package/core/server/data/tinybird/tests/api_kpis.yaml +0 -36
  29. package/core/server/data/tinybird/tests/api_top_locations.yaml +2 -31
  30. package/core/server/data/tinybird/tests/api_top_pages.yaml +1 -30
  31. package/core/server/data/tinybird/tests/api_top_sources.yaml +2 -41
  32. package/core/server/data/tinybird/tests/api_top_utm_campaigns.yaml +3 -33
  33. package/core/server/data/tinybird/tests/api_top_utm_contents.yaml +3 -39
  34. package/core/server/data/tinybird/tests/api_top_utm_mediums.yaml +3 -34
  35. package/core/server/data/tinybird/tests/api_top_utm_sources.yaml +3 -43
  36. package/core/server/data/tinybird/tests/api_top_utm_terms.yaml +3 -36
  37. package/core/server/services/email-address/EmailAddressService.js +4 -1
  38. package/core/server/services/email-address/EmailAddressService.ts +5 -1
  39. package/core/server/services/email-analytics/EmailAnalyticsService.js +70 -16
  40. package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +6 -0
  41. package/core/server/services/email-analytics/jobs/update-member-email-analytics/index.js +4 -3
  42. package/core/server/services/email-analytics/lib/queries.js +84 -0
  43. package/core/server/services/email-service/EmailEventProcessor.js +113 -13
  44. package/core/server/services/email-service/EmailEventStorage.js +191 -26
  45. package/core/server/services/email-service/EmailRenderer.js +10 -3
  46. package/core/server/services/email-service/SendingService.js +1 -1
  47. package/core/server/services/lib/MailgunClient.js +2 -1
  48. package/core/server/services/member-welcome-emails/jobs/index.js +2 -2
  49. package/core/server/services/members/members-api/repositories/MemberRepository.js +2 -1
  50. package/core/server/services/tinybird/TinybirdService.js +0 -3
  51. package/core/server/web/admin/app.js +3 -1
  52. package/core/server/web/admin/controller.js +2 -1
  53. package/core/shared/config/defaults.json +2 -1
  54. package/core/shared/config/helpers.js +13 -0
  55. package/core/shared/url-utils.js +4 -2
  56. package/package.json +5 -5
  57. package/tsconfig.tsbuildinfo +1 -1
  58. package/yarn.lock +158 -105
  59. package/components/tryghost-i18n-6.8.1.tgz +0 -0
  60. package/core/built/admin/assets/ghost-ca67b9eb701b867ae2a2fdd76cebdc17.css +0 -1
  61. package/core/built/admin/assets/ghost-dark-a5c3c5101d50a0af1f7b828ee387846d.css +0 -1
  62. package/core/frontend/web/middleware/serve-favicon.js +0 -72
  63. package/core/server/data/tinybird/endpoints/api_top_browsers.pipe +0 -54
  64. package/core/server/data/tinybird/endpoints/api_top_devices.pipe +0 -53
  65. package/core/server/data/tinybird/endpoints/api_top_os.pipe +0 -53
  66. package/core/server/data/tinybird/tests/api_top_browsers.yaml +0 -98
  67. package/core/server/data/tinybird/tests/api_top_devices.yaml +0 -75
  68. package/core/server/data/tinybird/tests/api_top_os.yaml +0 -80
@@ -12,28 +12,6 @@
12
12
  {"utm_content":"search_ad","visits":1}
13
13
  {"utm_content":"header_link","visits":1}
14
14
 
15
- - name: Filtered by browser - Chrome
16
- description: Filtered by browser - Chrome
17
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&browser=chrome
18
- expected_result: |
19
- {"utm_content":"post_123","visits":1}
20
- {"utm_content":"video_ad","visits":1}
21
- {"utm_content":"story_789","visits":1}
22
- {"utm_content":"banner_ad","visits":1}
23
-
24
- - name: Filtered by device - desktop
25
- description: Filtered by device - desktop
26
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop
27
- expected_result: |
28
- {"utm_content":"post_123","visits":1}
29
- {"utm_content":"video_ad","visits":1}
30
- {"utm_content":"story_789","visits":1}
31
- {"utm_content":"sponsored_post","visits":1}
32
- {"utm_content":"tweet_456","visits":1}
33
- {"utm_content":"banner_ad","visits":1}
34
- {"utm_content":"search_ad","visits":1}
35
- {"utm_content":"header_link","visits":1}
36
-
37
15
  - name: Filtered by location - UK
38
16
  description: Filtered by location - UK
39
17
  parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&location=GB
@@ -43,19 +21,6 @@
43
21
  {"utm_content":"banner_ad","visits":1}
44
22
  {"utm_content":"header_link","visits":1}
45
23
 
46
- - name: Filtered by OS - Windows
47
- description: Filtered by OS - Windows
48
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&os=windows
49
- expected_result: |
50
- {"utm_content":"post_123","visits":1}
51
- {"utm_content":"video_ad","visits":1}
52
- {"utm_content":"story_789","visits":1}
53
- {"utm_content":"sponsored_post","visits":1}
54
- {"utm_content":"tweet_456","visits":1}
55
- {"utm_content":"banner_ad","visits":1}
56
- {"utm_content":"search_ad","visits":1}
57
- {"utm_content":"header_link","visits":1}
58
-
59
24
  - name: Filtered by pathname - /about/
60
25
  description: Filtered by pathname - /about/
61
26
  parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&pathname=%2Fabout%2F
@@ -110,8 +75,7 @@
110
75
 
111
76
  - name: Test with multiple filters combined
112
77
  description: Test with multiple filters combined
113
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop&browser=firefox
78
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&source=bing.com&pathname=%2Fabout%2F
114
79
  expected_result: |
115
- {"utm_content":"sponsored_post","visits":1}
116
- {"utm_content":"tweet_456","visits":1}
117
- {"utm_content":"search_ad","visits":1}
80
+ {"utm_content":"story_789","visits":1}
81
+ {"utm_content":"header_link","visits":1}
@@ -10,26 +10,6 @@
10
10
  {"utm_medium":"display","visits":1}
11
11
  {"utm_medium":"email","visits":1}
12
12
 
13
- - name: Filtered by browser - Chrome
14
- description: Filtered by browser - Chrome
15
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&browser=chrome
16
- expected_result: |
17
- {"utm_medium":"social","visits":2}
18
- {"utm_medium":"organic","visits":1}
19
- {"utm_medium":"referral","visits":1}
20
- {"utm_medium":"display","visits":1}
21
-
22
- - name: Filtered by device - desktop
23
- description: Filtered by device - desktop
24
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop
25
- expected_result: |
26
- {"utm_medium":"social","visits":5}
27
- {"utm_medium":"cpc","visits":1}
28
- {"utm_medium":"organic","visits":1}
29
- {"utm_medium":"referral","visits":1}
30
- {"utm_medium":"display","visits":1}
31
- {"utm_medium":"email","visits":1}
32
-
33
13
  - name: Filtered by location - UK
34
14
  description: Filtered by location - UK
35
15
  parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&location=GB
@@ -41,17 +21,6 @@
41
21
  {"utm_medium":"display","visits":1}
42
22
  {"utm_medium":"email","visits":1}
43
23
 
44
- - name: Filtered by OS - Windows
45
- description: Filtered by OS - Windows
46
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&os=windows
47
- expected_result: |
48
- {"utm_medium":"social","visits":5}
49
- {"utm_medium":"cpc","visits":1}
50
- {"utm_medium":"organic","visits":1}
51
- {"utm_medium":"referral","visits":1}
52
- {"utm_medium":"display","visits":1}
53
- {"utm_medium":"email","visits":1}
54
-
55
24
  - name: Filtered by pathname - /about/
56
25
  description: Filtered by pathname - /about/
57
26
  parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&pathname=%2Fabout%2F
@@ -104,7 +73,7 @@
104
73
 
105
74
  - name: Test with multiple filters combined
106
75
  description: Test with multiple filters combined
107
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop&browser=firefox
76
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&source=bing.com&pathname=%2Fabout%2F
108
77
  expected_result: |
109
- {"utm_medium":"social","visits":3}
110
- {"utm_medium":"cpc","visits":1}
78
+ {"utm_medium":"social","visits":1}
79
+ {"utm_medium":"email","visits":1}
@@ -13,30 +13,6 @@
13
13
  {"utm_source":"partner_site","visits":1}
14
14
  {"utm_source":"newsletter","visits":1}
15
15
 
16
- - name: Filtered by browser - Chrome
17
- description: Filtered by browser - Chrome
18
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&browser=chrome
19
- expected_result: |
20
- {"utm_source":"google","visits":1}
21
- {"utm_source":"facebook","visits":1}
22
- {"utm_source":"bing","visits":1}
23
- {"utm_source":"instagram","visits":1}
24
- {"utm_source":"partner_site","visits":1}
25
-
26
- - name: Filtered by device - desktop
27
- description: Filtered by device - desktop
28
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop
29
- expected_result: |
30
- {"utm_source":"google","visits":2}
31
- {"utm_source":"linkedin","visits":1}
32
- {"utm_source":"twitter","visits":1}
33
- {"utm_source":"facebook","visits":1}
34
- {"utm_source":"bing","visits":1}
35
- {"utm_source":"reddit","visits":1}
36
- {"utm_source":"instagram","visits":1}
37
- {"utm_source":"partner_site","visits":1}
38
- {"utm_source":"newsletter","visits":1}
39
-
40
16
  - name: Filtered by location - UK
41
17
  description: Filtered by location - UK
42
18
  parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&location=GB
@@ -47,20 +23,6 @@
47
23
  {"utm_source":"partner_site","visits":1}
48
24
  {"utm_source":"newsletter","visits":1}
49
25
 
50
- - name: Filtered by OS - Windows
51
- description: Filtered by OS - Windows
52
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&os=windows
53
- expected_result: |
54
- {"utm_source":"google","visits":2}
55
- {"utm_source":"linkedin","visits":1}
56
- {"utm_source":"twitter","visits":1}
57
- {"utm_source":"facebook","visits":1}
58
- {"utm_source":"bing","visits":1}
59
- {"utm_source":"reddit","visits":1}
60
- {"utm_source":"instagram","visits":1}
61
- {"utm_source":"partner_site","visits":1}
62
- {"utm_source":"newsletter","visits":1}
63
-
64
26
  - name: Filtered by pathname - /about/
65
27
  description: Filtered by pathname - /about/
66
28
  parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&pathname=%2Fabout%2F
@@ -120,9 +82,7 @@
120
82
 
121
83
  - name: Test with multiple filters combined
122
84
  description: Test with multiple filters combined
123
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop&browser=firefox
85
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&source=bing.com&pathname=%2Fabout%2F
124
86
  expected_result: |
125
- {"utm_source":"linkedin","visits":1}
126
- {"utm_source":"twitter","visits":1}
127
- {"utm_source":"google","visits":1}
128
- {"utm_source":"reddit","visits":1}
87
+ {"utm_source":"instagram","visits":1}
88
+ {"utm_source":"newsletter","visits":1}
@@ -11,26 +11,6 @@
11
11
  {"utm_term":"new_feature","visits":1}
12
12
  {"utm_term":"announcement","visits":1}
13
13
 
14
- - name: Filtered by browser - Chrome
15
- description: Filtered by browser - Chrome
16
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&browser=chrome
17
- expected_result: |
18
- {"utm_term":"discount","visits":1}
19
- {"utm_term":"ghost_blog","visits":1}
20
- {"utm_term":"loyal_customers","visits":1}
21
-
22
- - name: Filtered by device - desktop
23
- description: Filtered by device - desktop
24
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop
25
- expected_result: |
26
- {"utm_term":"discount","visits":1}
27
- {"utm_term":"ghost_blog","visits":1}
28
- {"utm_term":"subscribers","visits":1}
29
- {"utm_term":"loyal_customers","visits":1}
30
- {"utm_term":"black_friday","visits":1}
31
- {"utm_term":"new_feature","visits":1}
32
- {"utm_term":"announcement","visits":1}
33
-
34
14
  - name: Filtered by location - UK
35
15
  description: Filtered by location - UK
36
16
  parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&location=GB
@@ -40,18 +20,6 @@
40
20
  {"utm_term":"subscribers","visits":1}
41
21
  {"utm_term":"new_feature","visits":1}
42
22
 
43
- - name: Filtered by OS - Windows
44
- description: Filtered by OS - Windows
45
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&os=windows
46
- expected_result: |
47
- {"utm_term":"discount","visits":1}
48
- {"utm_term":"ghost_blog","visits":1}
49
- {"utm_term":"subscribers","visits":1}
50
- {"utm_term":"loyal_customers","visits":1}
51
- {"utm_term":"black_friday","visits":1}
52
- {"utm_term":"new_feature","visits":1}
53
- {"utm_term":"announcement","visits":1}
54
-
55
23
  - name: Filtered by pathname - /about/
56
24
  description: Filtered by pathname - /about/
57
25
  parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&pathname=%2Fabout%2F
@@ -108,8 +76,7 @@
108
76
 
109
77
  - name: Test with multiple filters combined
110
78
  description: Test with multiple filters combined
111
- parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop&browser=firefox
79
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&source=bing.com&pathname=%2Fabout%2F
112
80
  expected_result: |
113
- {"utm_term":"black_friday","visits":1}
114
- {"utm_term":"new_feature","visits":1}
115
- {"utm_term":"announcement","visits":1}
81
+ {"utm_term":"subscribers","visits":1}
82
+ {"utm_term":"loyal_customers","visits":1}
@@ -83,9 +83,12 @@ class EmailAddressService {
83
83
  if (this.#labs.isSet('domainWarmup') && options.useFallbackAddress) {
84
84
  const fallbackEmail = this.fallbackEmail;
85
85
  if (fallbackEmail) {
86
+ if (!fallbackEmail.name) {
87
+ fallbackEmail.name = preferred.from.name || this.defaultFromEmail.name;
88
+ }
86
89
  return {
87
90
  from: fallbackEmail,
88
- replyTo: preferred.replyTo || preferred.from
91
+ replyTo: preferred.replyTo || preferred.from || this.defaultFromEmail
89
92
  };
90
93
  }
91
94
  else {
@@ -120,9 +120,13 @@ export class EmailAddressService {
120
120
  if (this.#labs.isSet('domainWarmup') && options.useFallbackAddress) {
121
121
  const fallbackEmail = this.fallbackEmail;
122
122
  if (fallbackEmail) {
123
+ if (!fallbackEmail.name) {
124
+ fallbackEmail.name = preferred.from.name || this.defaultFromEmail.name;
125
+ }
126
+
123
127
  return {
124
128
  from: fallbackEmail,
125
- replyTo: preferred.replyTo || preferred.from
129
+ replyTo: preferred.replyTo || preferred.from || this.defaultFromEmail
126
130
  };
127
131
  } else {
128
132
  logging.error('[EmailAddresses] Fallback email address is not configured, cannot use fallback address for sending email.');
@@ -437,26 +437,55 @@ module.exports = class EmailAnalyticsService {
437
437
  * @returns {Promise<void>}
438
438
  */
439
439
  async processEventBatch(events, result, fetchData) {
440
- for (const event of events) {
441
- const batchResult = await this.processEvent(event);
440
+ const useBatchProcessing = this.config.get('emailAnalytics:batchProcessing');
442
441
 
443
- // Save last event timestamp
444
- if (!fetchData.lastEventTimestamp || (event.timestamp && event.timestamp > fetchData.lastEventTimestamp)) {
445
- fetchData.lastEventTimestamp = event.timestamp; // don't need to keep db in sync; it'll fall back to last completed timestamp anyways
442
+ if (useBatchProcessing) {
443
+ // Batched mode: pre-fetch all recipients, then process events using cache
444
+ const emailIdentifications = events.map(event => ({
445
+ emailId: event.emailId,
446
+ providerId: event.providerId,
447
+ email: event.recipientEmail
448
+ }));
449
+
450
+ const recipientCache = await this.eventProcessor.batchGetRecipients(emailIdentifications);
451
+
452
+ for (const event of events) {
453
+ const batchResult = await this.processEvent(event, recipientCache);
454
+
455
+ // Save last event timestamp
456
+ if (!fetchData.lastEventTimestamp || (event.timestamp && event.timestamp > fetchData.lastEventTimestamp)) {
457
+ fetchData.lastEventTimestamp = event.timestamp;
458
+ }
459
+
460
+ result.merge(batchResult);
446
461
  }
447
462
 
448
- result.merge(batchResult);
463
+ // Flush all batched updates to the database
464
+ await this.eventProcessor.flushBatchedUpdates();
465
+ } else {
466
+ // Sequential mode: process events one by one (original behavior)
467
+ for (const event of events) {
468
+ const batchResult = await this.processEvent(event);
469
+
470
+ // Save last event timestamp
471
+ if (!fetchData.lastEventTimestamp || (event.timestamp && event.timestamp > fetchData.lastEventTimestamp)) {
472
+ fetchData.lastEventTimestamp = event.timestamp;
473
+ }
474
+
475
+ result.merge(batchResult);
476
+ }
449
477
  }
450
478
  }
451
479
 
452
480
  /**
453
481
  *
454
482
  * @param {{id: string, type: any; severity: any; recipientEmail: any; emailId?: string; providerId: string; timestamp: Date; error: {code: number; message: string; enhandedCode: string|number} | null}} event
483
+ * @param {Map<string, any>} [recipientCache] Optional cache for batched processing
455
484
  * @returns {Promise<EventProcessingResult>}
456
485
  */
457
- async processEvent(event) {
486
+ async processEvent(event, recipientCache) {
458
487
  if (event.type === 'delivered') {
459
- const recipient = await this.eventProcessor.handleDelivered({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
488
+ const recipient = await this.eventProcessor.handleDelivered({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp, recipientCache);
460
489
 
461
490
  if (recipient) {
462
491
  return new EventProcessingResult({
@@ -470,7 +499,7 @@ module.exports = class EmailAnalyticsService {
470
499
  }
471
500
 
472
501
  if (event.type === 'opened') {
473
- const recipient = await this.eventProcessor.handleOpened({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
502
+ const recipient = await this.eventProcessor.handleOpened({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp, recipientCache);
474
503
 
475
504
  if (recipient) {
476
505
  return new EventProcessingResult({
@@ -485,7 +514,7 @@ module.exports = class EmailAnalyticsService {
485
514
 
486
515
  if (event.type === 'failed') {
487
516
  if (event.severity === 'permanent') {
488
- const recipient = await this.eventProcessor.handlePermanentFailed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, {id: event.id, timestamp: event.timestamp, error: event.error});
517
+ const recipient = await this.eventProcessor.handlePermanentFailed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, {id: event.id, timestamp: event.timestamp, error: event.error}, recipientCache);
489
518
 
490
519
  if (recipient) {
491
520
  return new EventProcessingResult({
@@ -497,7 +526,7 @@ module.exports = class EmailAnalyticsService {
497
526
 
498
527
  return new EventProcessingResult({unprocessable: 1});
499
528
  } else {
500
- const recipient = await this.eventProcessor.handleTemporaryFailed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, {id: event.id, timestamp: event.timestamp, error: event.error});
529
+ const recipient = await this.eventProcessor.handleTemporaryFailed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, {id: event.id, timestamp: event.timestamp, error: event.error}, recipientCache);
501
530
 
502
531
  if (recipient) {
503
532
  return new EventProcessingResult({
@@ -512,7 +541,7 @@ module.exports = class EmailAnalyticsService {
512
541
  }
513
542
 
514
543
  if (event.type === 'unsubscribed') {
515
- const recipient = await this.eventProcessor.handleUnsubscribed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
544
+ const recipient = await this.eventProcessor.handleUnsubscribed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp, recipientCache);
516
545
 
517
546
  if (recipient) {
518
547
  return new EventProcessingResult({
@@ -526,7 +555,7 @@ module.exports = class EmailAnalyticsService {
526
555
  }
527
556
 
528
557
  if (event.type === 'complained') {
529
- const recipient = await this.eventProcessor.handleComplained({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
558
+ const recipient = await this.eventProcessor.handleComplained({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp, recipientCache);
530
559
 
531
560
  if (recipient) {
532
561
  return new EventProcessingResult({
@@ -547,15 +576,31 @@ module.exports = class EmailAnalyticsService {
547
576
  * @param {boolean} includeOpenedEvents
548
577
  */
549
578
  async aggregateStats({emailIds = [], memberIds = []}, includeOpenedEvents = true) {
579
+ const useBatchProcessing = this.config.get('emailAnalytics:batchProcessing');
580
+
550
581
  for (const emailId of emailIds) {
551
582
  await this.aggregateEmailStats(emailId, includeOpenedEvents);
552
583
  }
553
584
 
554
585
  // @ts-expect-error
555
586
  const memberMetric = this.prometheusClient?.getMetric('email_analytics_aggregate_member_stats_count');
556
- for (const memberId of memberIds) {
557
- await this.aggregateMemberStats(memberId);
558
- memberMetric?.inc();
587
+
588
+ if (useBatchProcessing) {
589
+ // Batched mode: process 100 members at a time
590
+ logging.info(`[EmailAnalytics] Aggregating stats for ${memberIds.length} members using BATCHED mode (batch size: 100)`);
591
+ const BATCH_SIZE = 100;
592
+ for (let i = 0; i < memberIds.length; i += BATCH_SIZE) {
593
+ const batch = memberIds.slice(i, i + BATCH_SIZE);
594
+ await this.aggregateMemberStatsBatch(batch);
595
+ memberMetric?.inc(batch.length);
596
+ }
597
+ } else {
598
+ // Sequential mode: process one member at a time
599
+ logging.info(`[EmailAnalytics] Aggregating stats for ${memberIds.length} members using SEQUENTIAL mode`);
600
+ for (const memberId of memberIds) {
601
+ await this.aggregateMemberStats(memberId);
602
+ memberMetric?.inc();
603
+ }
559
604
  }
560
605
  }
561
606
 
@@ -577,4 +622,13 @@ module.exports = class EmailAnalyticsService {
577
622
  async aggregateMemberStats(memberId) {
578
623
  return this.queries.aggregateMemberStats(memberId);
579
624
  }
625
+
626
+ /**
627
+ * Aggregate member stats for multiple members in a batch.
628
+ * @param {string[]} memberIds - Array of member IDs to aggregate stats for.
629
+ * @returns {Promise<void>}
630
+ */
631
+ async aggregateMemberStatsBatch(memberIds) {
632
+ return this.queries.aggregateMemberStatsBatch(memberIds);
633
+ }
580
634
  };
@@ -57,6 +57,10 @@ class EmailAnalyticsServiceWrapper {
57
57
  prometheusClient
58
58
  });
59
59
 
60
+ // Log the processing mode on initialization
61
+ const batchProcessingEnabled = config.get('emailAnalytics:batchProcessing');
62
+ logging.info(`[EmailAnalytics] Initialized with ${batchProcessingEnabled ? 'BATCHED' : 'SEQUENTIAL'} processing mode`);
63
+
60
64
  // We currently cannot trigger a non-offloaded job from the job manager
61
65
  // So the email analytics jobs simply emits an event.
62
66
  domainEvents.subscribe(StartEmailAnalyticsJobEvent, async () => {
@@ -81,10 +85,12 @@ class EmailAnalyticsServiceWrapper {
81
85
  const apiPercent = totalDurationMs > 0 ? Math.round((apiPollingTimeMs / totalDurationMs) * 100) : 0;
82
86
  const processingPercent = totalDurationMs > 0 ? Math.round((processingTimeMs / totalDurationMs) * 100) : 0;
83
87
  const aggregationPercent = totalDurationMs > 0 ? Math.round((aggregationTimeMs / totalDurationMs) * 100) : 0;
88
+ const batchMode = config.get('emailAnalytics:batchProcessing') ? 'BATCHED' : 'SEQUENTIAL';
84
89
 
85
90
  const logMessage = [
86
91
  `[EmailAnalytics] Job complete: ${jobType}`,
87
92
  `${eventCount} events in ${(totalDurationMs / 1000).toFixed(1)}s (${throughput.toFixed(2)} events/s)`,
93
+ `Mode: ${batchMode}`,
88
94
  `Timings: API ${(apiPollingTimeMs / 1000).toFixed(1)}s (${apiPercent}%) / Processing ${(processingTimeMs / 1000).toFixed(1)}s (${processingPercent}%) / Aggregation ${(aggregationTimeMs / 1000).toFixed(1)}s (${aggregationPercent}%)`,
89
95
  `Events: opened=${result.opened} delivered=${result.delivered} failed=${result.permanentFailed + result.temporaryFailed} unprocessable=${result.unprocessable}`
90
96
  ].join(' | ');
@@ -1,13 +1,14 @@
1
1
  const queries = require('../../lib/queries');
2
-
2
+
3
3
  /**
4
4
  * Updates email analytics for a specific member
5
- *
5
+ *
6
6
  * @param {Object} options - The options object
7
7
  * @param {string} options.memberId - The ID of the member to update analytics for
8
8
  * @returns {Promise<Object>} The result of the aggregation query (1/0)
9
9
  */
10
10
  module.exports = async function updateMemberEmailAnalytics({memberId}) {
11
- const result = await queries.aggregateMemberStats(memberId);
11
+ // Use the batch method with a single member for consistency
12
+ const result = await queries.aggregateMemberStatsBatch([memberId]);
12
13
  return result;
13
14
  };
@@ -201,5 +201,89 @@ module.exports = {
201
201
  await db.knex('members')
202
202
  .update(updateQuery)
203
203
  .where('id', memberId);
204
+ },
205
+
206
+ async aggregateMemberStatsBatch(memberIds) {
207
+ if (!memberIds || memberIds.length === 0) {
208
+ return;
209
+ }
210
+
211
+ // Batch query to get stats for all members at once
212
+ const stats = await db.knex('email_recipients')
213
+ .leftJoin('emails', 'emails.id', 'email_recipients.email_id')
214
+ .select(
215
+ 'email_recipients.member_id',
216
+ db.knex.raw('COUNT(email_recipients.id) as email_count'),
217
+ db.knex.raw('SUM(CASE WHEN email_recipients.opened_at IS NOT NULL THEN 1 ELSE 0 END) as email_opened_count'),
218
+ db.knex.raw('SUM(CASE WHEN emails.track_opens = 1 THEN 1 ELSE 0 END) as tracked_count')
219
+ )
220
+ .whereIn('email_recipients.member_id', memberIds)
221
+ .groupBy('email_recipients.member_id');
222
+
223
+ // Build update data for each member
224
+ const memberStatsMap = new Map();
225
+ for (const stat of stats) {
226
+ const emailOpenRate = stat.tracked_count >= MIN_EMAIL_COUNT_FOR_OPEN_RATE
227
+ ? Math.round((stat.email_opened_count / stat.tracked_count) * 100)
228
+ : null;
229
+
230
+ memberStatsMap.set(stat.member_id, {
231
+ email_count: stat.email_count,
232
+ email_opened_count: stat.email_opened_count,
233
+ email_open_rate: emailOpenRate
234
+ });
235
+ }
236
+
237
+ // Build CASE statements for batch update
238
+ const emailCountCases = [];
239
+ const emailOpenedCountCases = [];
240
+ const emailOpenRateCases = [];
241
+ const emailCountBindings = [];
242
+ const emailOpenedCountBindings = [];
243
+ const emailOpenRateBindings = [];
244
+
245
+ for (const memberId of memberIds) {
246
+ const memberStats = memberStatsMap.get(memberId) || {
247
+ email_count: 0,
248
+ email_opened_count: 0,
249
+ email_open_rate: null
250
+ };
251
+
252
+ emailCountCases.push(`WHEN ? THEN ?`);
253
+ emailCountBindings.push(memberId, memberStats.email_count);
254
+
255
+ emailOpenedCountCases.push(`WHEN ? THEN ?`);
256
+ emailOpenedCountBindings.push(memberId, memberStats.email_opened_count);
257
+
258
+ if (memberStats.email_open_rate !== null) {
259
+ emailOpenRateCases.push(`WHEN ? THEN ?`);
260
+ emailOpenRateBindings.push(memberId, memberStats.email_open_rate);
261
+ } else {
262
+ emailOpenRateCases.push(`WHEN ? THEN NULL`);
263
+ emailOpenRateBindings.push(memberId);
264
+ }
265
+ }
266
+
267
+ // Combine bindings in the order they appear in the SQL statement:
268
+ // 1. All bindings for email_count CASE statement
269
+ // 2. All bindings for email_opened_count CASE statement
270
+ // 3. All bindings for email_open_rate CASE statement
271
+ // 4. Member IDs for the WHERE IN clause
272
+ const bindings = [
273
+ ...emailCountBindings,
274
+ ...emailOpenedCountBindings,
275
+ ...emailOpenRateBindings,
276
+ ...memberIds
277
+ ];
278
+
279
+ // Execute batched update with CASE statements
280
+ await db.knex.raw(`
281
+ UPDATE members
282
+ SET
283
+ email_count = CASE id ${emailCountCases.join(' ')} END,
284
+ email_opened_count = CASE id ${emailOpenedCountCases.join(' ')} END,
285
+ email_open_rate = CASE id ${emailOpenRateCases.join(' ')} END
286
+ WHERE id IN (${memberIds.map(() => '?').join(',')})
287
+ `, bindings);
204
288
  }
205
289
  };