ghost 6.8.1 → 6.9.1

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 (69) hide show
  1. package/components/tryghost-i18n-6.9.1.tgz +0 -0
  2. package/content/themes/source/package.json +1 -1
  3. package/core/built/admin/assets/activitypub/activitypub.js +2 -2
  4. package/core/built/admin/assets/activitypub/{index-BbINZU9U.mjs → index-B29oZuTp.mjs} +2 -2
  5. package/core/built/admin/assets/activitypub/{index-C3M839De.mjs → index-C19nEXqT.mjs} +7998 -7923
  6. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-Dh1am4P6.mjs → CodeEditorView-C0HG4CwA.mjs} +2 -2
  7. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  8. package/core/built/admin/assets/admin-x-settings/{index-DqbTDHzA.mjs → index-Cjl9AHTm.mjs} +2 -2
  9. package/core/built/admin/assets/admin-x-settings/{index-SxxA3jSX.mjs → index-Cz3Y1eRN.mjs} +5 -5
  10. package/core/built/admin/assets/admin-x-settings/{index-Hwd1HJ0-.mjs → index-DRpMCs_c.mjs} +2 -2
  11. package/core/built/admin/assets/admin-x-settings/{modals-yhgQVHax.mjs → modals-DXD3MDLp.mjs} +8814 -8818
  12. package/core/built/admin/assets/{chunk.524.bc2b771f38286b4a51ed.js → chunk.524.56454d8add89b041f4df.js} +5 -5
  13. package/core/built/admin/assets/{chunk.582.a28f953895ae17fb3b24.js → chunk.582.a4916bd533becb58fd9c.js} +8 -8
  14. package/core/built/admin/assets/{ghost-7556359b8bd4ec08a6c23890b04bb56e.js → ghost-94d0fbb20e8e880fa9ba144cf26ab050.js} +27 -27
  15. package/core/built/admin/assets/ghost-dark-6c9cfa9c364e28c57e5983f68ec6f2fc.css +1 -0
  16. package/core/built/admin/assets/ghost-f724c1d53f5402f78a2d8cf8beb7c716.css +1 -0
  17. package/core/built/admin/assets/posts/posts.js +1 -1
  18. package/core/built/admin/assets/stats/stats.js +1 -1
  19. package/core/built/admin/index.html +4 -4
  20. package/core/frontend/public/robots.txt +1 -0
  21. package/core/frontend/web/middleware/index.js +0 -1
  22. package/core/frontend/web/routers/serve-favicon.js +56 -0
  23. package/core/frontend/web/site.js +2 -1
  24. package/core/server/data/tinybird/endpoints/api_kpis.pipe +0 -3
  25. package/core/server/data/tinybird/endpoints/api_top_locations.pipe +0 -3
  26. package/core/server/data/tinybird/endpoints/api_top_pages.pipe +0 -5
  27. package/core/server/data/tinybird/pipes/filtered_sessions.pipe +0 -3
  28. package/core/server/data/tinybird/scripts/analytics-generator.js +106 -2
  29. package/core/server/data/tinybird/tests/api_kpis.yaml +0 -36
  30. package/core/server/data/tinybird/tests/api_top_locations.yaml +2 -31
  31. package/core/server/data/tinybird/tests/api_top_pages.yaml +1 -30
  32. package/core/server/data/tinybird/tests/api_top_sources.yaml +2 -41
  33. package/core/server/data/tinybird/tests/api_top_utm_campaigns.yaml +3 -33
  34. package/core/server/data/tinybird/tests/api_top_utm_contents.yaml +3 -39
  35. package/core/server/data/tinybird/tests/api_top_utm_mediums.yaml +3 -34
  36. package/core/server/data/tinybird/tests/api_top_utm_sources.yaml +3 -43
  37. package/core/server/data/tinybird/tests/api_top_utm_terms.yaml +3 -36
  38. package/core/server/services/email-address/EmailAddressService.js +4 -1
  39. package/core/server/services/email-address/EmailAddressService.ts +5 -1
  40. package/core/server/services/email-analytics/EmailAnalyticsService.js +123 -22
  41. package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +6 -0
  42. package/core/server/services/email-analytics/jobs/update-member-email-analytics/index.js +4 -3
  43. package/core/server/services/email-analytics/lib/queries.js +84 -0
  44. package/core/server/services/email-service/EmailEventProcessor.js +113 -13
  45. package/core/server/services/email-service/EmailEventStorage.js +191 -26
  46. package/core/server/services/email-service/EmailRenderer.js +10 -3
  47. package/core/server/services/email-service/SendingService.js +1 -1
  48. package/core/server/services/lib/MailgunClient.js +2 -1
  49. package/core/server/services/member-welcome-emails/jobs/index.js +2 -2
  50. package/core/server/services/members/members-api/repositories/MemberRepository.js +2 -1
  51. package/core/server/services/tinybird/TinybirdService.js +0 -3
  52. package/core/server/web/admin/app.js +3 -1
  53. package/core/server/web/admin/controller.js +2 -1
  54. package/core/shared/config/defaults.json +2 -1
  55. package/core/shared/config/helpers.js +13 -0
  56. package/core/shared/url-utils.js +4 -2
  57. package/package.json +5 -5
  58. package/tsconfig.tsbuildinfo +1 -1
  59. package/yarn.lock +158 -105
  60. package/components/tryghost-i18n-6.8.1.tgz +0 -0
  61. package/core/built/admin/assets/ghost-ca67b9eb701b867ae2a2fdd76cebdc17.css +0 -1
  62. package/core/built/admin/assets/ghost-dark-a5c3c5101d50a0af1f7b828ee387846d.css +0 -1
  63. package/core/frontend/web/middleware/serve-favicon.js +0 -72
  64. package/core/server/data/tinybird/endpoints/api_top_browsers.pipe +0 -54
  65. package/core/server/data/tinybird/endpoints/api_top_devices.pipe +0 -53
  66. package/core/server/data/tinybird/endpoints/api_top_os.pipe +0 -53
  67. package/core/server/data/tinybird/tests/api_top_browsers.yaml +0 -98
  68. package/core/server/data/tinybird/tests/api_top_devices.yaml +0 -75
  69. 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.');
@@ -330,22 +330,56 @@ module.exports = class EmailAnalyticsService {
330
330
 
331
331
  // We keep the processing result here, so we also have a result in case of failures
332
332
  let processingResult = new EventProcessingResult();
333
+ // Track cumulative event counts separately since processingResult gets reset during intermediate aggregations
334
+ const cumulativeResult = new EventProcessingResult();
335
+ // Track all unique emailIds and memberIds that need aggregation
336
+ const allEmailIds = new Set();
337
+ const allMemberIds = new Set();
333
338
  let error = null;
334
339
 
335
340
  /**
336
341
  * Process a batch of events
337
342
  * @param {Array<Object>} events - Array of event objects to process
338
- * @param {EventProcessingResult} processingResult - Object to store the processing results
339
- * @param {FetchData} fetchData - Object containing fetch operation data
340
343
  * @returns {Promise<void>}
341
344
  */
342
345
  const processBatch = async (events) => {
343
346
  // Even if the fetching is interrupted because of an error, we still store the last event timestamp
344
347
  const processingStart = Date.now();
348
+ // Capture the state before processing to calculate delta
349
+ const beforeCounts = {
350
+ opened: processingResult.opened,
351
+ delivered: processingResult.delivered,
352
+ temporaryFailed: processingResult.temporaryFailed,
353
+ permanentFailed: processingResult.permanentFailed,
354
+ unsubscribed: processingResult.unsubscribed,
355
+ complained: processingResult.complained,
356
+ unhandled: processingResult.unhandled,
357
+ unprocessable: processingResult.unprocessable
358
+ };
359
+ const beforeEmailIds = new Set(processingResult.emailIds);
360
+ const beforeMemberIds = new Set(processingResult.memberIds);
361
+
345
362
  await this.processEventBatch(events, processingResult, fetchData);
346
363
  processingTimeMs += (Date.now() - processingStart);
347
364
  eventCount += events.length;
348
365
 
366
+ // Calculate delta (only new counts from this batch) and accumulate for final reporting
367
+ const batchDelta = new EventProcessingResult({
368
+ opened: processingResult.opened - beforeCounts.opened,
369
+ delivered: processingResult.delivered - beforeCounts.delivered,
370
+ temporaryFailed: processingResult.temporaryFailed - beforeCounts.temporaryFailed,
371
+ permanentFailed: processingResult.permanentFailed - beforeCounts.permanentFailed,
372
+ unsubscribed: processingResult.unsubscribed - beforeCounts.unsubscribed,
373
+ complained: processingResult.complained - beforeCounts.complained,
374
+ unhandled: processingResult.unhandled - beforeCounts.unhandled,
375
+ unprocessable: processingResult.unprocessable - beforeCounts.unprocessable,
376
+ emailIds: processingResult.emailIds.filter(id => !beforeEmailIds.has(id)),
377
+ memberIds: processingResult.memberIds.filter(id => !beforeMemberIds.has(id))
378
+ });
379
+ cumulativeResult.merge(batchDelta);
380
+ batchDelta.emailIds.forEach(id => allEmailIds.add(id));
381
+ batchDelta.memberIds.forEach(id => allMemberIds.add(id));
382
+
349
383
  // Every 5 minutes or 5000 members we do an aggregation and clear the processingResult
350
384
  // Otherwise we need to loop a lot of members afterwards, and this takes too long without updating the stat counts in between
351
385
  if ((Date.now() - lastAggregation > 5 * 60 * 1000 || processingResult.memberIds.length > 5000) && eventCount > 0) {
@@ -356,6 +390,9 @@ module.exports = class EmailAnalyticsService {
356
390
  await this.aggregateStats(processingResult, includeOpenedEvents);
357
391
  aggregationTimeMs += (Date.now() - aggregationStart);
358
392
  lastAggregation = Date.now();
393
+ // Remove aggregated emailIds and memberIds from tracking sets to avoid re-aggregating at the end
394
+ processingResult.emailIds.forEach(id => allEmailIds.delete(id));
395
+ processingResult.memberIds.forEach(id => allMemberIds.delete(id));
359
396
  processingResult = new EventProcessingResult();
360
397
  } catch (err) {
361
398
  logging.error('[EmailAnalytics] Error while aggregating stats');
@@ -386,10 +423,20 @@ module.exports = class EmailAnalyticsService {
386
423
  }
387
424
  }
388
425
 
389
- if (processingResult.memberIds.length > 0 || processingResult.emailIds.length > 0) {
426
+ // Final aggregation: aggregate any remaining events and ensure all emailIds are aggregated
427
+ // We need to aggregate all unique emailIds to ensure the emails table is updated
428
+ const finalEmailIds = Array.from(new Set([...processingResult.emailIds, ...allEmailIds]));
429
+ const finalMemberIds = Array.from(new Set([...processingResult.memberIds, ...allMemberIds]));
430
+
431
+ if (finalMemberIds.length > 0 || finalEmailIds.length > 0) {
390
432
  try {
391
433
  const aggregationStart = Date.now();
392
- await this.aggregateStats(processingResult, includeOpenedEvents);
434
+ // Create a result object with all emailIds and memberIds for final aggregation
435
+ const finalAggregationResult = {
436
+ emailIds: finalEmailIds,
437
+ memberIds: finalMemberIds
438
+ };
439
+ await this.aggregateStats(finalAggregationResult, includeOpenedEvents);
393
440
  aggregationTimeMs += (Date.now() - aggregationStart);
394
441
  } catch (err) {
395
442
  logging.error('[EmailAnalytics] Error while aggregating stats');
@@ -404,7 +451,7 @@ module.exports = class EmailAnalyticsService {
404
451
  // Small trick: if reached the end of new events, we are going to keep
405
452
  // fetching the same events because 'begin' won't change
406
453
  // So if we didn't have errors while fetching, and total events < maxEvents, increase lastEventTimestamp with one second
407
- if (!error && eventCount > 0 && eventCount < maxEvents && fetchData.lastEventTimestamp && fetchData.lastEventTimestamp.getTime() < Date.now() - 2000) {
454
+ if (!error && eventCount > 0 && fetchData.lastEventTimestamp && fetchData.lastEventTimestamp.getTime() < Date.now() - 2000) {
408
455
  // set the data on the db so we can store it for fetching after reboot
409
456
  await this.queries.setJobTimestamp(fetchData.jobName, 'finished', new Date(fetchData.lastEventTimestamp.getTime()));
410
457
  // increment and store in local memory
@@ -425,7 +472,7 @@ module.exports = class EmailAnalyticsService {
425
472
  apiPollingTimeMs,
426
473
  processingTimeMs,
427
474
  aggregationTimeMs,
428
- result: processingResult
475
+ result: cumulativeResult
429
476
  };
430
477
  }
431
478
 
@@ -437,26 +484,55 @@ module.exports = class EmailAnalyticsService {
437
484
  * @returns {Promise<void>}
438
485
  */
439
486
  async processEventBatch(events, result, fetchData) {
440
- for (const event of events) {
441
- const batchResult = await this.processEvent(event);
487
+ const useBatchProcessing = this.config.get('emailAnalytics:batchProcessing');
488
+
489
+ if (useBatchProcessing) {
490
+ // Batched mode: pre-fetch all recipients, then process events using cache
491
+ const emailIdentifications = events.map(event => ({
492
+ emailId: event.emailId,
493
+ providerId: event.providerId,
494
+ email: event.recipientEmail
495
+ }));
442
496
 
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
497
+ const recipientCache = await this.eventProcessor.batchGetRecipients(emailIdentifications);
498
+
499
+ for (const event of events) {
500
+ const batchResult = await this.processEvent(event, recipientCache);
501
+
502
+ // Save last event timestamp
503
+ if (!fetchData.lastEventTimestamp || (event.timestamp && event.timestamp > fetchData.lastEventTimestamp)) {
504
+ fetchData.lastEventTimestamp = event.timestamp;
505
+ }
506
+
507
+ result.merge(batchResult);
446
508
  }
447
509
 
448
- result.merge(batchResult);
510
+ // Flush all batched updates to the database
511
+ await this.eventProcessor.flushBatchedUpdates();
512
+ } else {
513
+ // Sequential mode: process events one by one (original behavior)
514
+ for (const event of events) {
515
+ const batchResult = await this.processEvent(event);
516
+
517
+ // Save last event timestamp
518
+ if (!fetchData.lastEventTimestamp || (event.timestamp && event.timestamp > fetchData.lastEventTimestamp)) {
519
+ fetchData.lastEventTimestamp = event.timestamp;
520
+ }
521
+
522
+ result.merge(batchResult);
523
+ }
449
524
  }
450
525
  }
451
526
 
452
527
  /**
453
528
  *
454
529
  * @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
530
+ * @param {Map<string, any>} [recipientCache] Optional cache for batched processing
455
531
  * @returns {Promise<EventProcessingResult>}
456
532
  */
457
- async processEvent(event) {
533
+ async processEvent(event, recipientCache) {
458
534
  if (event.type === 'delivered') {
459
- const recipient = await this.eventProcessor.handleDelivered({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
535
+ const recipient = await this.eventProcessor.handleDelivered({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp, recipientCache);
460
536
 
461
537
  if (recipient) {
462
538
  return new EventProcessingResult({
@@ -470,7 +546,7 @@ module.exports = class EmailAnalyticsService {
470
546
  }
471
547
 
472
548
  if (event.type === 'opened') {
473
- const recipient = await this.eventProcessor.handleOpened({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
549
+ const recipient = await this.eventProcessor.handleOpened({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp, recipientCache);
474
550
 
475
551
  if (recipient) {
476
552
  return new EventProcessingResult({
@@ -485,7 +561,7 @@ module.exports = class EmailAnalyticsService {
485
561
 
486
562
  if (event.type === 'failed') {
487
563
  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});
564
+ 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
565
 
490
566
  if (recipient) {
491
567
  return new EventProcessingResult({
@@ -497,7 +573,7 @@ module.exports = class EmailAnalyticsService {
497
573
 
498
574
  return new EventProcessingResult({unprocessable: 1});
499
575
  } 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});
576
+ 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
577
 
502
578
  if (recipient) {
503
579
  return new EventProcessingResult({
@@ -512,7 +588,7 @@ module.exports = class EmailAnalyticsService {
512
588
  }
513
589
 
514
590
  if (event.type === 'unsubscribed') {
515
- const recipient = await this.eventProcessor.handleUnsubscribed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
591
+ const recipient = await this.eventProcessor.handleUnsubscribed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp, recipientCache);
516
592
 
517
593
  if (recipient) {
518
594
  return new EventProcessingResult({
@@ -526,7 +602,7 @@ module.exports = class EmailAnalyticsService {
526
602
  }
527
603
 
528
604
  if (event.type === 'complained') {
529
- const recipient = await this.eventProcessor.handleComplained({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
605
+ const recipient = await this.eventProcessor.handleComplained({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp, recipientCache);
530
606
 
531
607
  if (recipient) {
532
608
  return new EventProcessingResult({
@@ -547,15 +623,31 @@ module.exports = class EmailAnalyticsService {
547
623
  * @param {boolean} includeOpenedEvents
548
624
  */
549
625
  async aggregateStats({emailIds = [], memberIds = []}, includeOpenedEvents = true) {
626
+ const useBatchProcessing = this.config.get('emailAnalytics:batchProcessing');
627
+
550
628
  for (const emailId of emailIds) {
551
629
  await this.aggregateEmailStats(emailId, includeOpenedEvents);
552
630
  }
553
631
 
554
632
  // @ts-expect-error
555
633
  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();
634
+
635
+ if (useBatchProcessing) {
636
+ // Batched mode: process 100 members at a time
637
+ logging.info(`[EmailAnalytics] Aggregating stats for ${memberIds.length} members using BATCHED mode (batch size: 100)`);
638
+ const BATCH_SIZE = 100;
639
+ for (let i = 0; i < memberIds.length; i += BATCH_SIZE) {
640
+ const batch = memberIds.slice(i, i + BATCH_SIZE);
641
+ await this.aggregateMemberStatsBatch(batch);
642
+ memberMetric?.inc(batch.length);
643
+ }
644
+ } else {
645
+ // Sequential mode: process one member at a time
646
+ logging.info(`[EmailAnalytics] Aggregating stats for ${memberIds.length} members using SEQUENTIAL mode`);
647
+ for (const memberId of memberIds) {
648
+ await this.aggregateMemberStats(memberId);
649
+ memberMetric?.inc();
650
+ }
559
651
  }
560
652
  }
561
653
 
@@ -577,4 +669,13 @@ module.exports = class EmailAnalyticsService {
577
669
  async aggregateMemberStats(memberId) {
578
670
  return this.queries.aggregateMemberStats(memberId);
579
671
  }
672
+
673
+ /**
674
+ * Aggregate member stats for multiple members in a batch.
675
+ * @param {string[]} memberIds - Array of member IDs to aggregate stats for.
676
+ * @returns {Promise<void>}
677
+ */
678
+ async aggregateMemberStatsBatch(memberIds) {
679
+ return this.queries.aggregateMemberStatsBatch(memberIds);
680
+ }
580
681
  };
@@ -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
  };