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.
- package/components/tryghost-i18n-6.9.0.tgz +0 -0
- package/core/built/admin/assets/activitypub/activitypub.js +2 -2
- package/core/built/admin/assets/activitypub/{index-BbINZU9U.mjs → index-B29oZuTp.mjs} +2 -2
- package/core/built/admin/assets/activitypub/{index-C3M839De.mjs → index-C19nEXqT.mjs} +7998 -7923
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-Dh1am4P6.mjs → CodeEditorView-BNKxdfRt.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{index-DqbTDHzA.mjs → index-B-_a183c.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-Hwd1HJ0-.mjs → index-CjRGpMVv.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-SxxA3jSX.mjs → index-Q0XmL0KU.mjs} +5 -5
- package/core/built/admin/assets/admin-x-settings/{modals-yhgQVHax.mjs → modals-omgXN6i-.mjs} +31 -27
- package/core/built/admin/assets/{chunk.524.0bee64e8bac52bb41823.js → chunk.524.774a2df444e2ffde4942.js} +7 -7
- package/core/built/admin/assets/{chunk.582.83c6478c40d90ef19d6b.js → chunk.582.ca4f05f3c39fda05b54c.js} +8 -8
- package/core/built/admin/assets/{ghost-7556359b8bd4ec08a6c23890b04bb56e.js → ghost-94d0fbb20e8e880fa9ba144cf26ab050.js} +27 -27
- package/core/built/admin/assets/ghost-dark-6c9cfa9c364e28c57e5983f68ec6f2fc.css +1 -0
- package/core/built/admin/assets/ghost-f724c1d53f5402f78a2d8cf8beb7c716.css +1 -0
- package/core/built/admin/assets/posts/posts.js +1 -1
- package/core/built/admin/assets/stats/stats.js +1 -1
- package/core/built/admin/index.html +4 -4
- package/core/frontend/public/robots.txt +1 -0
- package/core/frontend/web/middleware/index.js +0 -1
- package/core/frontend/web/routers/serve-favicon.js +56 -0
- package/core/frontend/web/site.js +2 -1
- package/core/server/data/tinybird/endpoints/api_kpis.pipe +0 -3
- package/core/server/data/tinybird/endpoints/api_top_locations.pipe +0 -3
- package/core/server/data/tinybird/endpoints/api_top_pages.pipe +0 -5
- package/core/server/data/tinybird/pipes/filtered_sessions.pipe +0 -3
- package/core/server/data/tinybird/scripts/analytics-generator.js +106 -2
- package/core/server/data/tinybird/tests/api_kpis.yaml +0 -36
- package/core/server/data/tinybird/tests/api_top_locations.yaml +2 -31
- package/core/server/data/tinybird/tests/api_top_pages.yaml +1 -30
- package/core/server/data/tinybird/tests/api_top_sources.yaml +2 -41
- package/core/server/data/tinybird/tests/api_top_utm_campaigns.yaml +3 -33
- package/core/server/data/tinybird/tests/api_top_utm_contents.yaml +3 -39
- package/core/server/data/tinybird/tests/api_top_utm_mediums.yaml +3 -34
- package/core/server/data/tinybird/tests/api_top_utm_sources.yaml +3 -43
- package/core/server/data/tinybird/tests/api_top_utm_terms.yaml +3 -36
- package/core/server/services/email-address/EmailAddressService.js +4 -1
- package/core/server/services/email-address/EmailAddressService.ts +5 -1
- package/core/server/services/email-analytics/EmailAnalyticsService.js +70 -16
- package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +6 -0
- package/core/server/services/email-analytics/jobs/update-member-email-analytics/index.js +4 -3
- package/core/server/services/email-analytics/lib/queries.js +84 -0
- package/core/server/services/email-service/EmailEventProcessor.js +113 -13
- package/core/server/services/email-service/EmailEventStorage.js +191 -26
- package/core/server/services/email-service/EmailRenderer.js +10 -3
- package/core/server/services/email-service/SendingService.js +1 -1
- package/core/server/services/lib/MailgunClient.js +2 -1
- package/core/server/services/member-welcome-emails/jobs/index.js +2 -2
- package/core/server/services/members/members-api/repositories/MemberRepository.js +2 -1
- package/core/server/services/tinybird/TinybirdService.js +0 -3
- package/core/server/web/admin/app.js +3 -1
- package/core/server/web/admin/controller.js +2 -1
- package/core/shared/config/defaults.json +2 -1
- package/core/shared/config/helpers.js +13 -0
- package/core/shared/url-utils.js +4 -2
- package/package.json +7 -7
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +162 -109
- package/components/tryghost-i18n-6.8.0.tgz +0 -0
- package/core/built/admin/assets/ghost-ca67b9eb701b867ae2a2fdd76cebdc17.css +0 -1
- package/core/built/admin/assets/ghost-dark-a5c3c5101d50a0af1f7b828ee387846d.css +0 -1
- package/core/frontend/web/middleware/serve-favicon.js +0 -72
- package/core/server/data/tinybird/endpoints/api_top_browsers.pipe +0 -54
- package/core/server/data/tinybird/endpoints/api_top_devices.pipe +0 -53
- package/core/server/data/tinybird/endpoints/api_top_os.pipe +0 -53
- package/core/server/data/tinybird/tests/api_top_browsers.yaml +0 -98
- package/core/server/data/tinybird/tests/api_top_devices.yaml +0 -75
- 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
.
|
|
57
|
-
.
|
|
58
|
-
|
|
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
|
|
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 (!
|
|
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 (
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
|
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
|
};
|
package/core/shared/url-utils.js
CHANGED
|
@@ -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';
|