ghost 6.8.0 → 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.0bee64e8bac52bb41823.js → chunk.524.774a2df444e2ffde4942.js} +7 -7
  12. package/core/built/admin/assets/{chunk.582.83c6478c40d90ef19d6b.js → chunk.582.ca4f05f3c39fda05b54c.js} +8 -8
  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 +7 -7
  57. package/tsconfig.tsbuildinfo +1 -1
  58. package/yarn.lock +162 -109
  59. package/components/tryghost-i18n-6.8.0.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
@@ -65,9 +65,10 @@ class EmailEventProcessor {
65
65
  /**
66
66
  * @param {EmailIdentification} emailIdentification
67
67
  * @param {Date} timestamp
68
+ * @param {Map<string, EmailRecipientInformation>} [recipientCache] Optional cache for batched processing
68
69
  */
69
- async handleDelivered(emailIdentification, timestamp) {
70
- const recipient = await this.getRecipient(emailIdentification);
70
+ async handleDelivered(emailIdentification, timestamp, recipientCache) {
71
+ const recipient = await this.getRecipient(emailIdentification, recipientCache);
71
72
  if (recipient) {
72
73
  const event = EmailDeliveredEvent.create({
73
74
  email: emailIdentification.email,
@@ -87,9 +88,10 @@ class EmailEventProcessor {
87
88
  /**
88
89
  * @param {EmailIdentification} emailIdentification
89
90
  * @param {Date} timestamp
91
+ * @param {Map<string, EmailRecipientInformation>} [recipientCache] Optional cache for batched processing
90
92
  */
91
- async handleOpened(emailIdentification, timestamp) {
92
- const recipient = await this.getRecipient(emailIdentification);
93
+ async handleOpened(emailIdentification, timestamp, recipientCache) {
94
+ const recipient = await this.getRecipient(emailIdentification, recipientCache);
93
95
  if (recipient) {
94
96
  const event = EmailOpenedEvent.create({
95
97
  email: emailIdentification.email,
@@ -108,9 +110,10 @@ class EmailEventProcessor {
108
110
  /**
109
111
  * @param {EmailIdentification} emailIdentification
110
112
  * @param {{id: string, timestamp: Date, error: {code: number; message: string; enhandedCode: string|number} | null}} event
113
+ * @param {Map<string, EmailRecipientInformation>} [recipientCache] Optional cache for batched processing
111
114
  */
112
- async handleTemporaryFailed(emailIdentification, {timestamp, error, id}) {
113
- const recipient = await this.getRecipient(emailIdentification);
115
+ async handleTemporaryFailed(emailIdentification, {timestamp, error, id}, recipientCache) {
116
+ const recipient = await this.getRecipient(emailIdentification, recipientCache);
114
117
  if (recipient) {
115
118
  const event = EmailTemporaryBouncedEvent.create({
116
119
  id,
@@ -131,9 +134,10 @@ class EmailEventProcessor {
131
134
  /**
132
135
  * @param {EmailIdentification} emailIdentification
133
136
  * @param {{id: string, timestamp: Date, error: {code: number; message: string; enhandedCode: string|number} | null}} event
137
+ * @param {Map<string, EmailRecipientInformation>} [recipientCache] Optional cache for batched processing
134
138
  */
135
- async handlePermanentFailed(emailIdentification, {timestamp, error, id}) {
136
- const recipient = await this.getRecipient(emailIdentification);
139
+ async handlePermanentFailed(emailIdentification, {timestamp, error, id}, recipientCache) {
140
+ const recipient = await this.getRecipient(emailIdentification, recipientCache);
137
141
  if (recipient) {
138
142
  const event = EmailBouncedEvent.create({
139
143
  id,
@@ -155,9 +159,10 @@ class EmailEventProcessor {
155
159
  /**
156
160
  * @param {EmailIdentification} emailIdentification
157
161
  * @param {Date} timestamp
162
+ * @param {Map<string, EmailRecipientInformation>} [recipientCache] Optional cache for batched processing
158
163
  */
159
- async handleUnsubscribed(emailIdentification, timestamp) {
160
- const recipient = await this.getRecipient(emailIdentification);
164
+ async handleUnsubscribed(emailIdentification, timestamp, recipientCache) {
165
+ const recipient = await this.getRecipient(emailIdentification, recipientCache);
161
166
  if (recipient) {
162
167
  const event = EmailUnsubscribedEvent.create({
163
168
  email: emailIdentification.email,
@@ -175,9 +180,10 @@ class EmailEventProcessor {
175
180
  /**
176
181
  * @param {EmailIdentification} emailIdentification
177
182
  * @param {Date} timestamp
183
+ * @param {Map<string, EmailRecipientInformation>} [recipientCache] Optional cache for batched processing
178
184
  */
179
- async handleComplained(emailIdentification, timestamp) {
180
- const recipient = await this.getRecipient(emailIdentification);
185
+ async handleComplained(emailIdentification, timestamp, recipientCache) {
186
+ const recipient = await this.getRecipient(emailIdentification, recipientCache);
181
187
  if (recipient) {
182
188
  const event = SpamComplaintEvent.create({
183
189
  email: emailIdentification.email,
@@ -196,9 +202,10 @@ class EmailEventProcessor {
196
202
  /**
197
203
  * @private
198
204
  * @param {EmailIdentification} emailIdentification
205
+ * @param {Map<string, EmailRecipientInformation>} [recipientCache] Optional cache for batched processing
199
206
  * @returns {Promise<EmailRecipientInformation|undefined>}
200
207
  */
201
- async getRecipient(emailIdentification) {
208
+ async getRecipient(emailIdentification, recipientCache) {
202
209
  if (!emailIdentification.emailId && !emailIdentification.providerId) {
203
210
  // Protection if both are null or undefined
204
211
  return;
@@ -211,6 +218,16 @@ class EmailEventProcessor {
211
218
  return;
212
219
  }
213
220
 
221
+ // Check cache first if batched processing is enabled
222
+ if (recipientCache) {
223
+ const key = `${emailIdentification.email}:${emailId}`;
224
+ const cached = recipientCache.get(key);
225
+ if (cached) {
226
+ return cached;
227
+ }
228
+ }
229
+
230
+ // Fall back to individual query for backwards compatibility
214
231
  const {id: emailRecipientId, member_id: memberId} = await this.#db.knex('email_recipients')
215
232
  .select('id', 'member_id')
216
233
  .where('member_email', emailIdentification.email)
@@ -262,6 +279,89 @@ class EmailEventProcessor {
262
279
  this.providerIdEmailIdMap[providerId] = emailId;
263
280
  return emailId;
264
281
  }
282
+
283
+ /**
284
+ * Batch lookup recipients for all events
285
+ * @param {Array<EmailIdentification>} emailIdentifications
286
+ * @returns {Promise<Map<string, EmailRecipientInformation>>}
287
+ */
288
+ async batchGetRecipients(emailIdentifications) {
289
+ const recipientCache = new Map();
290
+
291
+ if (!emailIdentifications || emailIdentifications.length === 0) {
292
+ return recipientCache;
293
+ }
294
+
295
+ // Step 1: Resolve all providerId -> emailId mappings
296
+ const providerIds = [...new Set(
297
+ emailIdentifications
298
+ .filter(e => e.providerId && !e.emailId)
299
+ .map(e => e.providerId)
300
+ )];
301
+
302
+ if (providerIds.length > 0) {
303
+ const providerIdMapping = await this.#db.knex('email_batches')
304
+ .select('provider_id', 'email_id')
305
+ .whereIn('provider_id', providerIds);
306
+
307
+ for (const row of providerIdMapping) {
308
+ this.providerIdEmailIdMap[row.provider_id] = row.email_id;
309
+ }
310
+ }
311
+
312
+ // Step 2: Build list of (email, emailId) pairs to lookup
313
+ const lookups = [];
314
+ for (const identification of emailIdentifications) {
315
+ const emailId = identification.emailId ?? this.providerIdEmailIdMap[identification.providerId];
316
+ if (emailId && identification.email) {
317
+ lookups.push({
318
+ email: identification.email,
319
+ emailId: emailId
320
+ });
321
+ }
322
+ }
323
+
324
+ if (lookups.length === 0) {
325
+ return recipientCache;
326
+ }
327
+
328
+ // Step 3: Batch query all recipients with OR conditions
329
+ // Build the WHERE clause with OR conditions
330
+ const recipientQuery = this.#db.knex('email_recipients')
331
+ .select('id', 'member_id', 'email_id', 'member_email');
332
+
333
+ // Add WHERE conditions - need to build complex OR query
334
+ recipientQuery.where(function () {
335
+ for (const lookup of lookups) {
336
+ this.orWhere(function () {
337
+ this.where('member_email', lookup.email)
338
+ .andWhere('email_id', lookup.emailId);
339
+ });
340
+ }
341
+ });
342
+
343
+ const recipients = await recipientQuery;
344
+
345
+ // Step 4: Build cache map keyed by "email:emailId"
346
+ for (const recipient of recipients) {
347
+ const key = `${recipient.member_email}:${recipient.email_id}`;
348
+ recipientCache.set(key, {
349
+ emailRecipientId: recipient.id,
350
+ memberId: recipient.member_id,
351
+ emailId: recipient.email_id
352
+ });
353
+ }
354
+
355
+ return recipientCache;
356
+ }
357
+
358
+ /**
359
+ * Flush any batched updates to the database
360
+ * @returns {Promise<void>}
361
+ */
362
+ async flushBatchedUpdates() {
363
+ return await this.#eventStorage.flushBatchedUpdates();
364
+ }
265
365
  }
266
366
 
267
367
  module.exports = EmailEventProcessor;
@@ -1,5 +1,6 @@
1
1
  const moment = require('moment-timezone');
2
2
  const logging = require('@tryghost/logging');
3
+ const config = require('../../../shared/config');
3
4
 
4
5
  class EmailEventStorage {
5
6
  #db;
@@ -7,6 +8,7 @@ class EmailEventStorage {
7
8
  #models;
8
9
  #emailSuppressionList;
9
10
  #prometheusClient;
11
+ #pendingUpdates;
10
12
 
11
13
  constructor({db, models, membersRepository, emailSuppressionList, prometheusClient}) {
12
14
  this.#db = db;
@@ -15,6 +17,13 @@ class EmailEventStorage {
15
17
  this.#emailSuppressionList = emailSuppressionList;
16
18
  this.#prometheusClient = prometheusClient;
17
19
 
20
+ // Initialize pending updates for batched processing
21
+ this.#pendingUpdates = {
22
+ delivered: new Map(), // recipientId -> timestamp
23
+ opened: new Map(), // recipientId -> timestamp
24
+ failed: new Map() // recipientId -> timestamp
25
+ };
26
+
18
27
  if (this.#prometheusClient) {
19
28
  this.#prometheusClient.registerCounter({
20
29
  name: 'email_analytics_events_stored',
@@ -25,38 +34,80 @@ class EmailEventStorage {
25
34
  }
26
35
 
27
36
  async handleDelivered(event) {
28
- // To properly handle events that are received out of order (this happens because of polling)
29
- // only set if delivered_at is null
30
- const rowCount = await this.#db.knex('email_recipients')
31
- .where('id', '=', event.emailRecipientId)
32
- .whereNull('delivered_at')
33
- .update({
34
- delivered_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
35
- });
36
- this.recordEventStored('delivered', rowCount);
37
+ const useBatchProcessing = config.get('emailAnalytics:batchProcessing');
38
+
39
+ if (useBatchProcessing) {
40
+ // Accumulate update for batch processing
41
+ const timestamp = moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss');
42
+ const existing = this.#pendingUpdates.delivered.get(event.emailRecipientId);
43
+
44
+ // Keep the earliest timestamp (out-of-order protection)
45
+ if (!existing || timestamp < existing) {
46
+ this.#pendingUpdates.delivered.set(event.emailRecipientId, timestamp);
47
+ }
48
+ } else {
49
+ // Sequential mode: immediate update
50
+ // To properly handle events that are received out of order (this happens because of polling)
51
+ // only set if delivered_at is null
52
+ const rowCount = await this.#db.knex('email_recipients')
53
+ .where('id', '=', event.emailRecipientId)
54
+ .whereNull('delivered_at')
55
+ .update({
56
+ delivered_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
57
+ });
58
+ this.recordEventStored('delivered', rowCount);
59
+ }
37
60
  }
38
61
 
39
62
  async handleOpened(event) {
40
- // To properly handle events that are received out of order (this happens because of polling)
41
- // only set if opened_at is null
42
- const rowCount = await this.#db.knex('email_recipients')
43
- .where('id', '=', event.emailRecipientId)
44
- .whereNull('opened_at')
45
- .update({
46
- opened_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
47
- });
48
- this.recordEventStored('opened', rowCount);
63
+ const useBatchProcessing = config.get('emailAnalytics:batchProcessing');
64
+
65
+ if (useBatchProcessing) {
66
+ // Accumulate update for batch processing
67
+ const timestamp = moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss');
68
+ const existing = this.#pendingUpdates.opened.get(event.emailRecipientId);
69
+
70
+ // Keep the earliest timestamp (out-of-order protection)
71
+ if (!existing || timestamp < existing) {
72
+ this.#pendingUpdates.opened.set(event.emailRecipientId, timestamp);
73
+ }
74
+ } else {
75
+ // Sequential mode: immediate update
76
+ // To properly handle events that are received out of order (this happens because of polling)
77
+ // only set if opened_at is null
78
+ const rowCount = await this.#db.knex('email_recipients')
79
+ .where('id', '=', event.emailRecipientId)
80
+ .whereNull('opened_at')
81
+ .update({
82
+ opened_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
83
+ });
84
+ this.recordEventStored('opened', rowCount);
85
+ }
49
86
  }
50
87
 
51
88
  async handlePermanentFailed(event) {
52
- // To properly handle events that are received out of order (this happens because of polling)
53
- // only set if failed_at is null
54
- await this.#db.knex('email_recipients')
55
- .where('id', '=', event.emailRecipientId)
56
- .whereNull('failed_at')
57
- .update({
58
- failed_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
59
- });
89
+ const useBatchProcessing = config.get('emailAnalytics:batchProcessing');
90
+
91
+ if (useBatchProcessing) {
92
+ // Accumulate update for batch processing
93
+ const timestamp = moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss');
94
+ const existing = this.#pendingUpdates.failed.get(event.emailRecipientId);
95
+
96
+ // Keep the earliest timestamp (out-of-order protection)
97
+ if (!existing || timestamp < existing) {
98
+ this.#pendingUpdates.failed.set(event.emailRecipientId, timestamp);
99
+ }
100
+ } else {
101
+ // Sequential mode: immediate update
102
+ // To properly handle events that are received out of order (this happens because of polling)
103
+ // only set if failed_at is null
104
+ await this.#db.knex('email_recipients')
105
+ .where('id', '=', event.emailRecipientId)
106
+ .whereNull('failed_at')
107
+ .update({
108
+ failed_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
109
+ });
110
+ }
60
111
  await this.saveFailure('permanent', event);
61
112
  }
62
113
 
@@ -182,6 +233,120 @@ class EmailEventStorage {
182
233
  logging.error('Error recording email analytics event stored', err);
183
234
  }
184
235
  }
236
+
237
+ /**
238
+ * Flush all batched updates to the database
239
+ * @returns {Promise<void>}
240
+ */
241
+ async flushBatchedUpdates() {
242
+ const deliveredCount = this.#pendingUpdates.delivered.size;
243
+ const openedCount = this.#pendingUpdates.opened.size;
244
+ const failedCount = this.#pendingUpdates.failed.size;
245
+
246
+ if (deliveredCount === 0 && openedCount === 0 && failedCount === 0) {
247
+ return; // Nothing to flush
248
+ }
249
+
250
+ // Flush delivered events
251
+ if (deliveredCount > 0) {
252
+ await this.#flushDeliveredUpdates();
253
+ }
254
+
255
+ // Flush opened events
256
+ if (openedCount > 0) {
257
+ await this.#flushOpenedUpdates();
258
+ }
259
+
260
+ // Flush failed events
261
+ if (failedCount > 0) {
262
+ await this.#flushFailedUpdates();
263
+ }
264
+
265
+ // Clear the pending updates
266
+ this.#pendingUpdates.delivered.clear();
267
+ this.#pendingUpdates.opened.clear();
268
+ this.#pendingUpdates.failed.clear();
269
+ }
270
+
271
+ /**
272
+ * @private
273
+ */
274
+ async #flushDeliveredUpdates() {
275
+ const updates = Array.from(this.#pendingUpdates.delivered.entries());
276
+ if (updates.length === 0) {
277
+ return;
278
+ }
279
+
280
+ // Build CASE statement for batched update
281
+ const recipientIds = updates.map(([id]) => id);
282
+ const caseClauses = updates.map(([id, timestamp]) => {
283
+ return `WHEN '${id}' THEN '${timestamp}'`;
284
+ }).join(' ');
285
+
286
+ const sql = `
287
+ UPDATE email_recipients
288
+ SET delivered_at = CASE id ${caseClauses} END
289
+ WHERE id IN (${recipientIds.map(() => '?').join(',')})
290
+ AND delivered_at IS NULL
291
+ `;
292
+
293
+ const rowCount = await this.#db.knex.raw(sql, recipientIds);
294
+ this.recordEventStored('delivered', updates.length);
295
+ return rowCount;
296
+ }
297
+
298
+ /**
299
+ * @private
300
+ */
301
+ async #flushOpenedUpdates() {
302
+ const updates = Array.from(this.#pendingUpdates.opened.entries());
303
+ if (updates.length === 0) {
304
+ return;
305
+ }
306
+
307
+ // Build CASE statement for batched update
308
+ const recipientIds = updates.map(([id]) => id);
309
+ const caseClauses = updates.map(([id, timestamp]) => {
310
+ return `WHEN '${id}' THEN '${timestamp}'`;
311
+ }).join(' ');
312
+
313
+ const sql = `
314
+ UPDATE email_recipients
315
+ SET opened_at = CASE id ${caseClauses} END
316
+ WHERE id IN (${recipientIds.map(() => '?').join(',')})
317
+ AND opened_at IS NULL
318
+ `;
319
+
320
+ const rowCount = await this.#db.knex.raw(sql, recipientIds);
321
+ this.recordEventStored('opened', updates.length);
322
+ return rowCount;
323
+ }
324
+
325
+ /**
326
+ * @private
327
+ */
328
+ async #flushFailedUpdates() {
329
+ const updates = Array.from(this.#pendingUpdates.failed.entries());
330
+ if (updates.length === 0) {
331
+ return;
332
+ }
333
+
334
+ // Build CASE statement for batched update
335
+ const recipientIds = updates.map(([id]) => id);
336
+ const caseClauses = updates.map(([id, timestamp]) => {
337
+ return `WHEN '${id}' THEN '${timestamp}'`;
338
+ }).join(' ');
339
+
340
+ const sql = `
341
+ UPDATE email_recipients
342
+ SET failed_at = CASE id ${caseClauses} END
343
+ WHERE id IN (${recipientIds.map(() => '?').join(',')})
344
+ AND failed_at IS NULL
345
+ `;
346
+
347
+ const rowCount = await this.#db.knex.raw(sql, recipientIds);
348
+ return rowCount;
349
+ }
185
350
  }
186
351
 
187
352
  module.exports = EmailEventStorage;
@@ -247,6 +247,12 @@ class EmailRenderer {
247
247
  return locale;
248
248
  }
249
249
 
250
+ /**
251
+ * @param {Post} post
252
+ * @param {Newsletter} newsletter
253
+ * @param {boolean} [useFallbackAddress]
254
+ * @returns {string|null}
255
+ */
250
256
  getFromAddress(post, newsletter, useFallbackAddress = false) {
251
257
  // Clean from address to ensure DMARC alignment
252
258
  const addresses = this.#emailAddressService.getAddress({
@@ -259,9 +265,10 @@ class EmailRenderer {
259
265
  /**
260
266
  * @param {Post} post
261
267
  * @param {Newsletter} newsletter
268
+ * @param {boolean} [useFallbackAddress]
262
269
  * @returns {string|null}
263
270
  */
264
- getReplyToAddress(post, newsletter) {
271
+ getReplyToAddress(post, newsletter, useFallbackAddress = false) {
265
272
  const replyToAddress = newsletter.get('sender_reply_to');
266
273
 
267
274
  if (replyToAddress === 'support') {
@@ -269,13 +276,13 @@ class EmailRenderer {
269
276
  }
270
277
 
271
278
  if (replyToAddress === 'newsletter' && !this.#emailAddressService.managedEmailEnabled) {
272
- return this.getFromAddress(post, newsletter);
279
+ return this.getFromAddress(post, newsletter, useFallbackAddress);
273
280
  }
274
281
 
275
282
  const addresses = this.#emailAddressService.getAddress({
276
283
  from: this.#getRawFromAddress(post, newsletter),
277
284
  replyTo: replyToAddress === 'newsletter' ? undefined : {address: replyToAddress}
278
- });
285
+ }, {useFallbackAddress});
279
286
 
280
287
  if (addresses.replyTo) {
281
288
  return EmailAddressParser.stringify(addresses.replyTo);
@@ -138,7 +138,7 @@ class SendingService {
138
138
  return await this.#emailProvider.send({
139
139
  subject: this.#emailRenderer.getSubject(post, isTestEmail),
140
140
  from: this.#emailRenderer.getFromAddress(post, newsletter, !!options.useFallbackAddress),
141
- replyTo: this.#emailRenderer.getReplyToAddress(post, newsletter) ?? undefined,
141
+ replyTo: this.#emailRenderer.getReplyToAddress(post, newsletter, !!options.useFallbackAddress) ?? undefined,
142
142
  html: emailBody.html,
143
143
  plaintext: emailBody.plaintext,
144
144
  recipients,
@@ -71,7 +71,8 @@ module.exports = class MailgunClient {
71
71
  text: messageContent.plaintext,
72
72
  'recipient-variables': JSON.stringify(recipientData),
73
73
  'h:Sender': message.from,
74
- 'h:Auto-Submitted': 'auto-generated'
74
+ 'h:Auto-Submitted': 'auto-generated',
75
+ 'h:X-Auto-Response-Suppress': 'OOF, AutoReply'
75
76
  };
76
77
 
77
78
  // Do we have a custom List-Unsubscribe header set?
@@ -1,6 +1,6 @@
1
1
  const path = require('path');
2
2
  const jobsService = require('../../jobs');
3
- const labs = require('../../../../shared/labs');
3
+ const config = require('../../../../shared/config');
4
4
 
5
5
  let hasScheduled = {
6
6
  processOutbox: false
@@ -8,7 +8,7 @@ let hasScheduled = {
8
8
 
9
9
  module.exports = {
10
10
  async scheduleMemberWelcomeEmailJob() {
11
- if (!labs.isSet('welcomeEmails')) {
11
+ if (!config.get('memberWelcomeEmailTestInbox')) {
12
12
  return false;
13
13
  }
14
14
 
@@ -8,6 +8,7 @@ const ObjectId = require('bson-objectid').default;
8
8
  const {NotFoundError} = require('@tryghost/errors');
9
9
  const validator = require('@tryghost/validator');
10
10
  const crypto = require('crypto');
11
+ const config = require('../../../../../shared/config');
11
12
 
12
13
  const messages = {
13
14
  noStripeConnection: 'Cannot {action} without a Stripe Connection',
@@ -337,7 +338,7 @@ module.exports = class MemberRepository {
337
338
 
338
339
  const memberAddOptions = {...(options || {}), withRelated};
339
340
  let member;
340
- if (this._labsService.isSet('welcomeEmails') && WELCOME_EMAIL_SOURCES.includes(source)) {
341
+ if (config.get('memberWelcomeEmailTestInbox') && WELCOME_EMAIL_SOURCES.includes(source)) {
341
342
  const runMemberCreation = async (transacting) => {
342
343
  const newMember = await this._Member.add({
343
344
  ...memberData,
@@ -48,10 +48,7 @@ const TINYBIRD_PIPES = [
48
48
  'api_kpis',
49
49
  'api_active_visitors',
50
50
  'api_post_visitor_counts',
51
- 'api_top_browsers',
52
- 'api_top_devices',
53
51
  'api_top_locations',
54
- 'api_top_os',
55
52
  'api_top_pages',
56
53
  'api_top_sources',
57
54
  'api_top_utm_sources',
@@ -23,8 +23,10 @@ module.exports = function setupAdminApp() {
23
23
  // produced below should be split into separate 'Cache-Control' entry.
24
24
  // For reference see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#validation_2
25
25
 
26
+ const adminAssetsPath = path.join(config.getAdminAssetsPath(), 'assets');
27
+
26
28
  adminApp.use('/assets', serveStatic(
27
- path.join(config.get('paths').adminAssets, 'assets'), {
29
+ adminAssetsPath, {
28
30
  // @NOTE: the maxAge config passed below are in milliseconds and the config
29
31
  // is specified in seconds. See https://github.com/expressjs/serve-static/issues/150 for more context
30
32
  maxAge: config.get('caching:admin:maxAge') * 1000,
@@ -29,7 +29,8 @@ module.exports = function adminController(req, res) {
29
29
  // CASE: trigger update check unit and let it run in background, don't block the admin rendering
30
30
  updateCheck();
31
31
 
32
- const templatePath = path.resolve(config.get('paths').adminAssets, 'index.html');
32
+ const adminAssetsPath = config.getAdminAssetsPath();
33
+ const templatePath = path.resolve(adminAssetsPath, 'index.html');
33
34
  const headers = {};
34
35
 
35
36
  try {
@@ -218,6 +218,7 @@
218
218
  "enableTipsAndDonations": true,
219
219
  "emailAnalytics": {
220
220
  "enabled": true,
221
+ "batchProcessing": false,
221
222
  "metrics": {
222
223
  "openThroughput": {
223
224
  "enabled": false,
@@ -302,5 +303,5 @@
302
303
  "captureLinkClickBadMemberUuid": false
303
304
  },
304
305
  "disableJSBackups": false,
305
- "memberWelcomeEmailTestInbox" : ""
306
+ "memberWelcomeEmailTestInbox": ""
306
307
  }
@@ -97,6 +97,18 @@ const getContentPath = function getContentPath(type) {
97
97
  }
98
98
  };
99
99
 
100
+ /**
101
+ * Get the path to admin assets, switching between Ember and React admin based on USE_REACT_SHELL env var
102
+ * @returns {string}
103
+ */
104
+ const getAdminAssetsPath = function getAdminAssetsPath() {
105
+ const useReactShell = process.env.USE_REACT_SHELL === 'true';
106
+ if (useReactShell) {
107
+ return path.join(this.get('paths:appRoot'), '../../apps/admin/dist');
108
+ }
109
+ return this.get('paths:adminAssets');
110
+ };
111
+
100
112
  /**
101
113
  * @typedef ConfigHelpers
102
114
  * @property {isPrivacyDisabledFn} isPrivacyDisabled
@@ -105,6 +117,7 @@ const getContentPath = function getContentPath(type) {
105
117
  module.exports.bindAll = (nconf) => {
106
118
  nconf.isPrivacyDisabled = isPrivacyDisabled.bind(nconf);
107
119
  nconf.getContentPath = getContentPath.bind(nconf);
120
+ nconf.getAdminAssetsPath = getAdminAssetsPath.bind(nconf);
108
121
  nconf.getBackendMountPath = getBackendMountPath.bind(nconf);
109
122
  nconf.getFrontendMountPath = getFrontendMountPath.bind(nconf);
110
123
  };
@@ -6,6 +6,10 @@ const urlUtils = new UrlUtils({
6
6
  getSubdir: config.getSubdir,
7
7
  getSiteUrl: config.getSiteUrl,
8
8
  getAdminUrl: config.getAdminUrl,
9
+ assetBaseUrls: {
10
+ media: config.get('urls:media'),
11
+ files: config.get('urls:files')
12
+ },
9
13
  slugs: config.get('slugs').protected,
10
14
  redirectCacheMaxAge: config.get('caching:301:maxAge'),
11
15
  baseApiPath: BASE_API_PATH
@@ -13,5 +17,3 @@ const urlUtils = new UrlUtils({
13
17
 
14
18
  module.exports = urlUtils;
15
19
  module.exports.BASE_API_PATH = BASE_API_PATH;
16
- module.exports.STATIC_MEDIA_URL_PREFIX = 'content/media';
17
- module.exports.STATIC_FILES_URL_PREFIX = 'content/files';