ghost 6.5.3 → 6.6.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 (31) hide show
  1. package/components/tryghost-i18n-6.6.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-BhKEFypa.mjs → index-C11K46Pd.mjs} +2 -2
  5. package/core/built/admin/assets/activitypub/{index-BpGYxosT.mjs → index-iut24bY_.mjs} +29133 -29074
  6. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-opzyn619.mjs → CodeEditorView-Cba-7G35.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-DEKggYGh.mjs → index-B6TY7YrM.mjs} +8 -4
  9. package/core/built/admin/assets/admin-x-settings/{index-Bl8-oPss.mjs → index-CiBRhRi0.mjs} +2 -2
  10. package/core/built/admin/assets/admin-x-settings/{modals-Dfgf0rk6.mjs → modals-E4jV5dSK.mjs} +4 -4
  11. package/core/built/admin/assets/{chunk.524.4d89fd583d7826ebd59b.js → chunk.524.04ed4161680a5dfe50c0.js} +7 -7
  12. package/core/built/admin/assets/{chunk.582.1d26ce1d009f6cb286d2.js → chunk.582.655b5639c4462c229e75.js} +9 -9
  13. package/core/built/admin/assets/{ghost-6caf3f9221e33783e229a3e10b593fae.js → ghost-dd34586f2d693640a66edb96be8ea6c2.js} +807 -1048
  14. package/core/built/admin/assets/posts/posts.js +3 -3
  15. package/core/built/admin/assets/stats/stats.js +46 -42
  16. package/core/built/admin/index.html +3 -3
  17. package/core/server/services/VerificationTrigger.js +4 -1
  18. package/core/server/services/email-analytics/EmailAnalyticsService.js +58 -30
  19. package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +78 -23
  20. package/core/server/services/email-analytics/jobs/index.js +1 -1
  21. package/core/server/services/email-service/email-templates/template.hbs +2 -2
  22. package/core/server/services/lib/MailgunClient.js +4 -1
  23. package/core/server/services/mail/GhostMailer.js +1 -1
  24. package/core/server/services/members/SingleUseTokenProvider.js +4 -0
  25. package/core/server/services/members/members-api/controllers/RouterController.js +6 -6
  26. package/core/server/services/public-config/config.js +1 -1
  27. package/core/shared/config/defaults.json +10 -1
  28. package/core/shared/labs.js +2 -1
  29. package/package.json +3 -3
  30. package/yarn.lock +13 -0
  31. package/components/tryghost-i18n-6.5.3.tgz +0 -0
@@ -6,7 +6,7 @@
6
6
  <title>Ghost</title>
7
7
 
8
8
 
9
- <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.5%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%2237bd1e3e4d%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%2270b59aade7%22%2C%22activitypubFilename%22%3A%22activitypub.js%22%2C%22activitypubHash%22%3A%223a9db432aa%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%2252d8643b3a%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%227449bfe668%22%2C%22activitypubRemoteConfigUrl%22%3A%22%2F.ghost%2Factivitypub%2Fstable%2Fclient-config%22%7D" />
9
+ <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.6%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%2237bd1e3e4d%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%225b5d9a0d3d%22%2C%22activitypubFilename%22%3A%22activitypub.js%22%2C%22activitypubHash%22%3A%2240c618ae11%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%229a34075d6a%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%224b9d666e6c%22%2C%22activitypubRemoteConfigUrl%22%3A%22%2F.ghost%2Factivitypub%2Fstable%2Fclient-config%22%7D" />
10
10
 
11
11
  <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1, minimal-ui, viewport-fit=cover" />
12
12
  <meta name="pinterest" content="nopin" />
@@ -49,7 +49,7 @@
49
49
 
50
50
  <script src="assets/vendor-aed0068cf9b67d042dd23a6343545b7b.js"></script>
51
51
  <script src="assets/chunk.397.a720333cfffc99c47e71.js"></script>
52
- <script src="assets/chunk.524.4d89fd583d7826ebd59b.js"></script>
53
- <script src="assets/ghost-6caf3f9221e33783e229a3e10b593fae.js"></script>
52
+ <script src="assets/chunk.524.04ed4161680a5dfe50c0.js"></script>
53
+ <script src="assets/ghost-dd34586f2d693640a66edb96be8ea6c2.js"></script>
54
54
  </body>
55
55
  </html>
@@ -43,7 +43,10 @@ class VerificationTrigger {
43
43
  this._eventRepository = eventRepository;
44
44
 
45
45
  this._handleMemberCreatedEvent = this._handleMemberCreatedEvent.bind(this);
46
- DomainEvents.subscribe(MemberCreatedEvent, this._handleMemberCreatedEvent);
46
+
47
+ if (!this._isVerified()) {
48
+ DomainEvents.subscribe(MemberCreatedEvent, this._handleMemberCreatedEvent);
49
+ }
47
50
  }
48
51
 
49
52
  get _apiTriggerThreshold() {
@@ -24,9 +24,32 @@ const errors = require('@tryghost/errors');
24
24
  * @typedef {'delivered' | 'opened' | 'failed' | 'unsubscribed' | 'complained'} EmailAnalyticsEvent
25
25
  */
26
26
 
27
+ /**
28
+ * @typedef {object} EmailAnalyticsFetchResult
29
+ * @property {number} eventCount - The number of events fetched
30
+ * @property {number} apiPollingTimeMs - Time spent polling the API in milliseconds
31
+ * @property {number} processingTimeMs - Time spent processing events in milliseconds
32
+ * @property {number} aggregationTimeMs - Time spent aggregating stats in milliseconds
33
+ * @property {EventProcessingResult} result - The processing result with event breakdown
34
+ */
35
+
27
36
  const TRUST_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
28
37
  const FETCH_LATEST_END_MARGIN_MS = 1 * 60 * 1000; // Do not fetch events newer than 1 minute (yet). Reduces the chance of having missed events in fetchLatest.
29
38
 
39
+ /**
40
+ * Helper function to create an empty fetch result
41
+ * @returns {EmailAnalyticsFetchResult}
42
+ */
43
+ function createEmptyResult() {
44
+ return {
45
+ eventCount: 0,
46
+ apiPollingTimeMs: 0,
47
+ processingTimeMs: 0,
48
+ aggregationTimeMs: 0,
49
+ result: new EventProcessingResult()
50
+ };
51
+ }
52
+
30
53
  module.exports = class EmailAnalyticsService {
31
54
  config;
32
55
  settings;
@@ -125,7 +148,7 @@ module.exports = class EmailAnalyticsService {
125
148
  * Fetches the latest opened events.
126
149
  * @param {Object} options - The options for fetching events.
127
150
  * @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch.
128
- * @returns {Promise<number>} The total number of events fetched.
151
+ * @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
129
152
  */
130
153
  async fetchLatestOpenedEvents({maxEvents = Infinity} = {}) {
131
154
  const begin = await this.getLastOpenedEventTimestamp();
@@ -134,7 +157,7 @@ module.exports = class EmailAnalyticsService {
134
157
  if (end <= begin) {
135
158
  // Skip for now
136
159
  logging.info('[EmailAnalytics] Skipping fetchLatestOpenedEvents because end (' + end + ') is before begin (' + begin + ')');
137
- return 0;
160
+ return createEmptyResult();
138
161
  }
139
162
 
140
163
  return await this.#fetchEvents(this.#fetchLatestOpenedData, {begin, end, maxEvents, eventTypes: ['opened']});
@@ -144,7 +167,7 @@ module.exports = class EmailAnalyticsService {
144
167
  * Fetches the latest non-opened events.
145
168
  * @param {Object} options - The options for fetching events.
146
169
  * @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch.
147
- * @returns {Promise<number>} The total number of events fetched.
170
+ * @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
148
171
  */
149
172
  async fetchLatestNonOpenedEvents({maxEvents = Infinity} = {}) {
150
173
  const begin = await this.getLastNonOpenedEventTimestamp();
@@ -153,7 +176,7 @@ module.exports = class EmailAnalyticsService {
153
176
  if (end <= begin) {
154
177
  // Skip for now
155
178
  logging.info('[EmailAnalytics] Skipping fetchLatestNonOpenedEvents because end (' + end + ') is before begin (' + begin + ')');
156
- return 0;
179
+ return createEmptyResult();
157
180
  }
158
181
 
159
182
  return await this.#fetchEvents(this.#fetchLatestNonOpenedData, {begin, end, maxEvents, eventTypes: ['delivered', 'failed', 'unsubscribed', 'complained']});
@@ -163,6 +186,7 @@ module.exports = class EmailAnalyticsService {
163
186
  * Fetches events that are older than 30 minutes, because then the 'storage' of the Mailgun API is stable. And we are sure we don't miss any events.
164
187
  * @param {object} options
165
188
  * @param {number} [options.maxEvents] Not a strict maximum. We stop fetching after we reached the maximum AND received at least one event after begin (not equal) to prevent deadlocks.
189
+ * @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
166
190
  */
167
191
  async fetchMissing({maxEvents = Infinity} = {}) {
168
192
  const begin = await this.getLastMissingEventTimestamp();
@@ -178,7 +202,7 @@ module.exports = class EmailAnalyticsService {
178
202
  if (end <= begin) {
179
203
  // Skip for now
180
204
  logging.info('[EmailAnalytics] Skipping fetchMissing because end (' + end + ') is before begin (' + begin + ')');
181
- return 0;
205
+ return createEmptyResult();
182
206
  }
183
207
 
184
208
  return await this.#fetchEvents(this.#fetchMissingData, {begin, end, maxEvents});
@@ -233,18 +257,18 @@ module.exports = class EmailAnalyticsService {
233
257
  * @method fetchScheduled
234
258
  * @param {Object} [options] - The options for fetching scheduled events.
235
259
  * @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch.
236
- * @returns {Promise<number>} The number of events fetched.
260
+ * @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
237
261
  */
238
262
  async fetchScheduled({maxEvents = Infinity} = {}) {
239
263
  if (!this.#fetchScheduledData || !this.#fetchScheduledData.schedule) {
240
264
  // Nothing scheduled
241
- return 0;
265
+ return createEmptyResult();
242
266
  }
243
267
 
244
268
  if (this.#fetchScheduledData.canceled) {
245
269
  // Skip for now
246
270
  this.#fetchScheduledData = null;
247
- return 0;
271
+ return createEmptyResult();
248
272
  }
249
273
 
250
274
  let begin = this.#fetchScheduledData.schedule.begin;
@@ -262,11 +286,11 @@ module.exports = class EmailAnalyticsService {
262
286
  running: false,
263
287
  jobName: 'email-analytics-scheduled'
264
288
  };
265
- return 0;
289
+ return createEmptyResult();
266
290
  }
267
291
 
268
- const count = await this.#fetchEvents(this.#fetchScheduledData, {begin, end, maxEvents});
269
- if (count === 0 || this.#fetchScheduledData.canceled) {
292
+ const fetchResult = await this.#fetchEvents(this.#fetchScheduledData, {begin, end, maxEvents});
293
+ if (fetchResult.eventCount === 0 || this.#fetchScheduledData.canceled) {
270
294
  // Reset the scheduled fetch
271
295
  this.#fetchScheduledData = {
272
296
  running: false,
@@ -275,7 +299,7 @@ module.exports = class EmailAnalyticsService {
275
299
  }
276
300
 
277
301
  this.queries.setJobTimestamp(this.#fetchScheduledData.jobName, 'finished', this.#fetchScheduledData.lastEventTimestamp);
278
- return count;
302
+ return fetchResult;
279
303
  }
280
304
  /**
281
305
  * Start fetching analytics and store the data of the progress inside fetchData
@@ -285,18 +309,21 @@ module.exports = class EmailAnalyticsService {
285
309
  * @param {Date} options.end - End date for fetching events
286
310
  * @param {number} [options.maxEvents=Infinity] - Maximum number of events to fetch. Not a strict maximum. We stop fetching after we reached the maximum AND received at least one event after begin (not equal) to prevent deadlocks.
287
311
  * @param {EmailAnalyticsEvent[]} [options.eventTypes] - Array of event types to fetch. If not provided, Mailgun will return all event types.
288
- * @returns {Promise<number>} The number of events fetched
312
+ * @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
289
313
  */
290
314
  async #fetchEvents(fetchData, {begin, end, maxEvents = Infinity, eventTypes = null}) {
291
315
  // Start where we left of, or the last stored event in the database, or start 30 minutes ago if we have nothing available
292
- logging.info('[EmailAnalytics] Fetching from ' + begin.toISOString() + ' until ' + end.toISOString() + ' (maxEvents: ' + maxEvents + ')');
293
-
294
316
  // Store that we started fetching
295
317
  fetchData.running = true;
296
318
  fetchData.lastStarted = new Date();
297
319
  fetchData.lastBegin = begin;
298
320
  this.queries.setJobTimestamp(fetchData.jobName, 'started', begin);
299
321
 
322
+ // Timing metrics
323
+ let apiPollingTimeMs = 0;
324
+ let processingTimeMs = 0;
325
+ let aggregationTimeMs = 0;
326
+
300
327
  let lastAggregation = Date.now();
301
328
  let eventCount = 0;
302
329
  const includeOpenedEvents = eventTypes?.includes('opened') ?? false;
@@ -314,7 +341,9 @@ module.exports = class EmailAnalyticsService {
314
341
  */
315
342
  const processBatch = async (events) => {
316
343
  // Even if the fetching is interrupted because of an error, we still store the last event timestamp
344
+ const processingStart = Date.now();
317
345
  await this.processEventBatch(events, processingResult, fetchData);
346
+ processingTimeMs += (Date.now() - processingStart);
318
347
  eventCount += events.length;
319
348
 
320
349
  // Every 5 minutes or 5000 members we do an aggregation and clear the processingResult
@@ -323,7 +352,9 @@ module.exports = class EmailAnalyticsService {
323
352
  // Aggregate and clear the processingResult
324
353
  // We do this here because otherwise it could take a long time before the new events are visible in the stats
325
354
  try {
355
+ const aggregationStart = Date.now();
326
356
  await this.aggregateStats(processingResult, includeOpenedEvents);
357
+ aggregationTimeMs += (Date.now() - aggregationStart);
327
358
  lastAggregation = Date.now();
328
359
  processingResult = new EventProcessingResult();
329
360
  } catch (err) {
@@ -341,10 +372,10 @@ module.exports = class EmailAnalyticsService {
341
372
 
342
373
  try {
343
374
  for (const provider of this.providers) {
375
+ const apiStart = Date.now();
344
376
  await provider.fetchLatest(processBatch, {begin, end, maxEvents, events: eventTypes});
377
+ apiPollingTimeMs += (Date.now() - apiStart);
345
378
  }
346
-
347
- logging.info('[EmailAnalytics] Fetching finished');
348
379
  } catch (err) {
349
380
  if (err.message !== 'Fetching canceled') {
350
381
  logging.error('[EmailAnalytics] Error while fetching');
@@ -357,7 +388,9 @@ module.exports = class EmailAnalyticsService {
357
388
 
358
389
  if (processingResult.memberIds.length > 0 || processingResult.emailIds.length > 0) {
359
390
  try {
391
+ const aggregationStart = Date.now();
360
392
  await this.aggregateStats(processingResult, includeOpenedEvents);
393
+ aggregationTimeMs += (Date.now() - aggregationStart);
361
394
  } catch (err) {
362
395
  logging.error('[EmailAnalytics] Error while aggregating stats');
363
396
  logging.error(err);
@@ -372,13 +405,11 @@ module.exports = class EmailAnalyticsService {
372
405
  // fetching the same events because 'begin' won't change
373
406
  // So if we didn't have errors while fetching, and total events < maxEvents, increase lastEventTimestamp with one second
374
407
  if (!error && eventCount > 0 && eventCount < maxEvents && fetchData.lastEventTimestamp && fetchData.lastEventTimestamp.getTime() < Date.now() - 2000) {
375
- logging.info('[EmailAnalytics] Reached end of new events, increasing lastEventTimestamp with one second');
376
408
  // set the data on the db so we can store it for fetching after reboot
377
409
  await this.queries.setJobTimestamp(fetchData.jobName, 'finished', new Date(fetchData.lastEventTimestamp.getTime()));
378
410
  // increment and store in local memory
379
411
  fetchData.lastEventTimestamp = new Date(fetchData.lastEventTimestamp.getTime() + 1000);
380
412
  } else {
381
- logging.info('[EmailAnalytics] No new events found');
382
413
  // set job status to finished
383
414
  await this.queries.setJobStatus(fetchData.jobName, 'finished');
384
415
  }
@@ -388,7 +419,14 @@ module.exports = class EmailAnalyticsService {
388
419
  if (error) {
389
420
  throw error;
390
421
  }
391
- return eventCount;
422
+
423
+ return {
424
+ eventCount,
425
+ apiPollingTimeMs,
426
+ processingTimeMs,
427
+ aggregationTimeMs,
428
+ result: processingResult
429
+ };
392
430
  }
393
431
 
394
432
  /**
@@ -509,17 +547,9 @@ module.exports = class EmailAnalyticsService {
509
547
  * @param {boolean} includeOpenedEvents
510
548
  */
511
549
  async aggregateStats({emailIds = [], memberIds = []}, includeOpenedEvents = true) {
512
- let startTime = Date.now();
513
- logging.info(`[EmailAnalytics] Aggregating for ${emailIds.length} emails`);
514
-
515
550
  for (const emailId of emailIds) {
516
551
  await this.aggregateEmailStats(emailId, includeOpenedEvents);
517
552
  }
518
- let endTime = Date.now() - startTime;
519
- logging.info(`[EmailAnalytics] Aggregating for ${emailIds.length} emails took ${endTime}ms`);
520
-
521
- startTime = Date.now();
522
- logging.info(`[EmailAnalytics] Aggregating for ${memberIds.length} members`);
523
553
 
524
554
  // @ts-expect-error
525
555
  const memberMetric = this.prometheusClient?.getMetric('email_analytics_aggregate_member_stats_count');
@@ -527,8 +557,6 @@ module.exports = class EmailAnalyticsService {
527
557
  await this.aggregateMemberStats(memberId);
528
558
  memberMetric?.inc();
529
559
  }
530
- endTime = Date.now() - startTime;
531
- logging.info(`[EmailAnalytics] Aggregating for ${memberIds.length} members took ${endTime}ms`);
532
560
  }
533
561
 
534
562
  /**
@@ -1,4 +1,6 @@
1
1
  const logging = require('@tryghost/logging');
2
+ const metrics = require('@tryghost/metrics');
3
+ const config = require('../../../shared/config');
2
4
 
3
5
  class EmailAnalyticsServiceWrapper {
4
6
  init() {
@@ -13,7 +15,6 @@ class EmailAnalyticsServiceWrapper {
13
15
  const {EmailRecipientFailure, EmailSpamComplaintEvent, Email} = require('../../models');
14
16
  const StartEmailAnalyticsJobEvent = require('./events/StartEmailAnalyticsJobEvent');
15
17
  const domainEvents = require('@tryghost/domain-events');
16
- const config = require('../../../shared/config');
17
18
  const settings = require('../../../shared/settings-cache');
18
19
  const db = require('../../data/db');
19
20
  const queries = require('./lib/queries');
@@ -62,51 +63,100 @@ class EmailAnalyticsServiceWrapper {
62
63
  });
63
64
  }
64
65
 
66
+ /**
67
+ * Log comprehensive job completion with timing metrics
68
+ * @param {string} jobType - Type of job (e.g., 'latest-opened', 'latest', 'missing', 'scheduled')
69
+ * @param {object} fetchResult - The fetch result from EmailAnalyticsService
70
+ * @param {number} totalDurationMs - Total duration in milliseconds
71
+ */
72
+ _logJobCompletion(jobType, fetchResult, totalDurationMs) {
73
+ const {eventCount, apiPollingTimeMs, processingTimeMs, aggregationTimeMs, result} = fetchResult;
74
+
75
+ if (eventCount === 0) {
76
+ return;
77
+ }
78
+
79
+ const throughput = totalDurationMs > 0 ? eventCount / (totalDurationMs / 1000) : 0;
80
+ const apiPercent = totalDurationMs > 0 ? Math.round((apiPollingTimeMs / totalDurationMs) * 100) : 0;
81
+ const processingPercent = totalDurationMs > 0 ? Math.round((processingTimeMs / totalDurationMs) * 100) : 0;
82
+ const aggregationPercent = totalDurationMs > 0 ? Math.round((aggregationTimeMs / totalDurationMs) * 100) : 0;
83
+
84
+ const logMessage = [
85
+ `[EmailAnalytics] Job complete: ${jobType}`,
86
+ `${eventCount} events in ${(totalDurationMs / 1000).toFixed(1)}s (${throughput.toFixed(2)} events/s)`,
87
+ `Timings: API ${(apiPollingTimeMs / 1000).toFixed(1)}s (${apiPercent}%) / Processing ${(processingTimeMs / 1000).toFixed(1)}s (${processingPercent}%) / Aggregation ${(aggregationTimeMs / 1000).toFixed(1)}s (${aggregationPercent}%)`,
88
+ `Events: opened=${result.opened} delivered=${result.delivered} failed=${result.permanentFailed + result.temporaryFailed} unprocessable=${result.unprocessable}`
89
+ ].join(' | ');
90
+
91
+ logging.info(logMessage);
92
+
93
+ // We're only concerned with open throughput as this is displayed to users and is most sensitive to being up to date
94
+ if (jobType === 'latest-opened') {
95
+ const openThroughputEnabled = config.get('emailAnalytics:metrics:openThroughput:enabled');
96
+ const openThroughputThreshold = config.get('emailAnalytics:metrics:openThroughput:threshold') || 0;
97
+ if (openThroughputEnabled && eventCount >= openThroughputThreshold) {
98
+ metrics.metric('email-analytics-open-throughput', {
99
+ value: throughput,
100
+ events: eventCount,
101
+ duration: totalDurationMs
102
+ });
103
+ }
104
+ }
105
+ }
106
+
65
107
  async fetchLatestOpenedEvents({maxEvents} = {maxEvents: Infinity}) {
66
- logging.info('[EmailAnalytics] Fetch latest opened events started');
108
+ const beginTimestamp = await this.service.getLastOpenedEventTimestamp();
109
+ const lagMinutes = (Date.now() - beginTimestamp.getTime()) / 60000;
110
+ const lagThreshold = config.get('emailAnalytics:openedJobLagWarningMinutes');
111
+
112
+ // NOTE: We only update the begin timestamp when we process events, so there's cases where we can have a false positive
113
+ // - Ghost or Mailgun outages
114
+ // - Lack of actual email activity
115
+ if (lagThreshold && lagMinutes > lagThreshold) {
116
+ logging.warn(`[EmailAnalytics] Opened events processing is ${lagMinutes.toFixed(1)} minutes behind (threshold: ${lagThreshold})`);
117
+ }
67
118
 
68
119
  const fetchStartDate = new Date();
69
- const totalEvents = await this.service.fetchLatestOpenedEvents({maxEvents});
70
- const fetchEndDate = new Date();
120
+ const fetchResult = await this.service.fetchLatestOpenedEvents({maxEvents});
121
+ const totalDuration = Date.now() - fetchStartDate.getTime();
71
122
 
72
- logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (latest opens)`);
73
- return totalEvents;
123
+ this._logJobCompletion('latest-opened', fetchResult, totalDuration);
124
+
125
+ return fetchResult.eventCount;
74
126
  }
75
127
 
76
128
  async fetchLatestNonOpenedEvents({maxEvents} = {maxEvents: Infinity}) {
77
- logging.info('[EmailAnalytics] Fetch latest non-opened events started');
78
-
79
129
  const fetchStartDate = new Date();
80
- const totalEvents = await this.service.fetchLatestNonOpenedEvents({maxEvents});
81
- const fetchEndDate = new Date();
130
+ const fetchResult = await this.service.fetchLatestNonOpenedEvents({maxEvents});
131
+ const totalDuration = Date.now() - fetchStartDate.getTime();
82
132
 
83
- logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (latest)`);
84
- return totalEvents;
133
+ this._logJobCompletion('latest', fetchResult, totalDuration);
134
+
135
+ return fetchResult.eventCount;
85
136
  }
86
137
 
87
138
  async fetchMissing({maxEvents} = {maxEvents: Infinity}) {
88
- logging.info('[EmailAnalytics] Fetch missing events started');
89
-
90
139
  const fetchStartDate = new Date();
91
- const totalEvents = await this.service.fetchMissing({maxEvents});
92
- const fetchEndDate = new Date();
140
+ const fetchResult = await this.service.fetchMissing({maxEvents});
141
+ const totalDuration = Date.now() - fetchStartDate.getTime();
93
142
 
94
- logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (missing)`);
95
- return totalEvents;
143
+ this._logJobCompletion('missing', fetchResult, totalDuration);
144
+
145
+ return fetchResult.eventCount;
96
146
  }
97
147
 
98
148
  async fetchScheduled({maxEvents}) {
99
149
  if (maxEvents < 300) {
100
150
  return 0;
101
151
  }
102
- logging.info('[EmailAnalytics] Fetch scheduled events started');
103
152
 
104
153
  const fetchStartDate = new Date();
105
- const totalEvents = await this.service.fetchScheduled({maxEvents});
106
- const fetchEndDate = new Date();
154
+ const fetchResult = await this.service.fetchScheduled({maxEvents});
155
+ const totalDuration = Date.now() - fetchStartDate.getTime();
156
+
157
+ this._logJobCompletion('scheduled', fetchResult, totalDuration);
107
158
 
108
- logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (scheduled)`);
109
- return totalEvents;
159
+ return fetchResult.eventCount;
110
160
  }
111
161
 
112
162
  async startFetch() {
@@ -144,6 +194,11 @@ class EmailAnalyticsServiceWrapper {
144
194
  return;
145
195
  }
146
196
 
197
+ // Log summary if no events were found across all jobs
198
+ if (c1 + c2 + c3 + c4 === 0) {
199
+ logging.info('[EmailAnalytics] Job complete - No events');
200
+ }
201
+
147
202
  this.fetching = false;
148
203
  } catch (e) {
149
204
  logging.error(e, 'Error while fetching email analytics');
@@ -10,7 +10,7 @@ module.exports = {
10
10
  async scheduleRecurringJobs(skipEmailCheck = false) {
11
11
  if (
12
12
  !hasScheduled &&
13
- config.get('emailAnalytics') &&
13
+ config.get('emailAnalytics:enabled') &&
14
14
  config.get('backgroundJobs:emailAnalytics') &&
15
15
  !process.env.NODE_ENV.startsWith('test')
16
16
  ) {
@@ -7,7 +7,7 @@
7
7
  <title>{{post.title}}</title>
8
8
  {{>styles}}
9
9
  </head>
10
- <body>
10
+ <body data-testid="email-preview-body">
11
11
  <span class="preheader">{{preheader}}</span>
12
12
  <!-- SPACING TO AVOID BODY TEXT BEING DUPLICATED IN PREVIEW TEXT -->
13
13
  <div style="display:none; max-height:0; overflow:hidden; mso-hide: all;" aria-hidden="true" role="presentation">
@@ -178,7 +178,7 @@
178
178
  <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
179
179
  <tr>
180
180
  <td class="wrapper">
181
- <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
181
+ <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" data-testid="email-preview-content">
182
182
  <tr class="post-content-row">
183
183
  <td class="{{classes.body}}">
184
184
  <!-- POST CONTENT START -->
@@ -213,7 +213,10 @@ module.exports = class MailgunClient {
213
213
  const totalDuration = overallEndTime - overallStartTime;
214
214
  const averageBatchTime = batchCount > 0 ? totalBatchTime / batchCount : 0;
215
215
 
216
- logging.info(`[MailgunClient fetchEvents]: Processed ${batchCount} batches in ${(totalDuration / 1000).toFixed(2)}s. Average batch time: ${(averageBatchTime / 1000).toFixed(2)}s`);
216
+ // Only log if we actually processed batches
217
+ if (batchCount > 0) {
218
+ logging.info(`[MailgunClient fetchEvents]: Processed ${batchCount} batches in ${(totalDuration / 1000).toFixed(2)}s. Average batch time: ${(averageBatchTime / 1000).toFixed(2)}s`);
219
+ }
217
220
  } catch (error) {
218
221
  logging.error(error);
219
222
  throw error;
@@ -135,7 +135,7 @@ module.exports = class GhostMailer {
135
135
  if (tags.length > 0) {
136
136
  messageToSend['o:tag'] = tags;
137
137
  }
138
- if (settingsCache.get('emailTrackOpens')) {
138
+ if (settingsCache.get('email_track_opens')) {
139
139
  messageToSend['o:tracking-opens'] = true;
140
140
  }
141
141
  }
@@ -397,6 +397,10 @@ class SingleUseTokenProvider {
397
397
  * @returns {void}
398
398
  */
399
399
  _validateTimeSinceFirstUsage(model) {
400
+ if (model.get('used_count') === 0 && !model.get('first_used_at')) {
401
+ return;
402
+ }
403
+
400
404
  const createdAtEpoch = model.get('created_at').getTime();
401
405
  const firstUsedAtEpoch = model.get('first_used_at')?.getTime() ?? createdAtEpoch;
402
406
  const timeSinceFirstUsage = Date.now() - firstUsedAtEpoch;
@@ -705,19 +705,19 @@ module.exports = class RouterController {
705
705
  });
706
706
  }
707
707
 
708
- const isValidOTC = await tokenProvider.verifyOTC(otcRef, otc);
709
- if (!isValidOTC) {
708
+ const tokenValue = await tokenProvider.getTokenByRef(otcRef);
709
+ if (!tokenValue) {
710
710
  throw new errors.BadRequestError({
711
711
  message: tpl(messages.invalidCode),
712
- code: 'INVALID_OTC'
712
+ code: 'INVALID_OTC_REF'
713
713
  });
714
714
  }
715
715
 
716
- const tokenValue = await tokenProvider.getTokenByRef(otcRef);
717
- if (!tokenValue) {
716
+ const isValidOTC = await tokenProvider.verifyOTC(otcRef, otc);
717
+ if (!isValidOTC) {
718
718
  throw new errors.BadRequestError({
719
719
  message: tpl(messages.invalidCode),
720
- code: 'INVALID_OTC_REF'
720
+ code: 'INVALID_OTC'
721
721
  });
722
722
  }
723
723
 
@@ -17,7 +17,7 @@ module.exports = function getConfigProperties() {
17
17
  enableDeveloperExperiments: config.get('enableDeveloperExperiments') || false,
18
18
  stripeDirect: config.get('stripeDirect'),
19
19
  mailgunIsConfigured: !!(config.get('bulkEmail') && config.get('bulkEmail').mailgun),
20
- emailAnalytics: config.get('emailAnalytics'),
20
+ emailAnalytics: config.get('emailAnalytics:enabled'),
21
21
  hostSettings: config.get('hostSettings'),
22
22
  tenor: config.get('tenor'),
23
23
  pintura: config.get('pintura'),
@@ -216,7 +216,16 @@
216
216
  "stripeDirect": false,
217
217
  "enableStripePromoCodes": false,
218
218
  "enableTipsAndDonations": true,
219
- "emailAnalytics": true,
219
+ "emailAnalytics": {
220
+ "enabled": true,
221
+ "metrics": {
222
+ "openThroughput": {
223
+ "enabled": false,
224
+ "threshold": 500
225
+ }
226
+ },
227
+ "openedJobLagWarningMinutes": 30
228
+ },
220
229
  "linkClickTrackingCacheMemberUuid": false,
221
230
  "backgroundJobs": {
222
231
  "emailAnalytics": true,
@@ -49,7 +49,8 @@ const PRIVATE_FEATURES = [
49
49
  'emailCustomization',
50
50
  'tagsX',
51
51
  'utmTracking',
52
- 'emailUniqueid'
52
+ 'emailUniqueid',
53
+ 'welcomeEmails'
53
54
  ];
54
55
 
55
56
  module.exports.GA_KEYS = [...GA_FEATURES];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghost",
3
- "version": "6.5.3",
3
+ "version": "6.6.1",
4
4
  "description": "The professional publishing platform",
5
5
  "author": "Ghost Foundation",
6
6
  "homepage": "https://ghost.org",
@@ -86,7 +86,7 @@
86
86
  "@tryghost/helpers": "1.1.97",
87
87
  "@tryghost/html-to-plaintext": "1.0.4",
88
88
  "@tryghost/http-cache-utils": "0.1.20",
89
- "@tryghost/i18n": "file:components/tryghost-i18n-6.5.3.tgz",
89
+ "@tryghost/i18n": "file:components/tryghost-i18n-6.6.1.tgz",
90
90
  "@tryghost/image-transform": "1.4.6",
91
91
  "@tryghost/job-manager": "1.0.3",
92
92
  "@tryghost/kg-card-factory": "5.1.2",
@@ -273,7 +273,7 @@
273
273
  "jackspeak": "2.3.6",
274
274
  "moment": "2.24.0",
275
275
  "moment-timezone": "0.5.45",
276
- "@tryghost/i18n": "file:components/tryghost-i18n-6.5.3.tgz"
276
+ "@tryghost/i18n": "file:components/tryghost-i18n-6.6.1.tgz"
277
277
  },
278
278
  "nx": {
279
279
  "targets": {
package/yarn.lock CHANGED
@@ -5238,6 +5238,19 @@
5238
5238
  "@radix-ui/react-use-previous" "1.1.1"
5239
5239
  "@radix-ui/react-use-size" "1.1.1"
5240
5240
 
5241
+ "@radix-ui/react-switch@^1.2.6":
5242
+ version "1.2.6"
5243
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
5244
+ integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
5245
+ dependencies:
5246
+ "@radix-ui/primitive" "1.1.3"
5247
+ "@radix-ui/react-compose-refs" "1.1.2"
5248
+ "@radix-ui/react-context" "1.1.2"
5249
+ "@radix-ui/react-primitive" "2.1.3"
5250
+ "@radix-ui/react-use-controllable-state" "1.2.2"
5251
+ "@radix-ui/react-use-previous" "1.1.1"
5252
+ "@radix-ui/react-use-size" "1.1.1"
5253
+
5241
5254
  "@radix-ui/react-tabs@1.1.12":
5242
5255
  version "1.1.12"
5243
5256
  resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz#99b3522c73db9263f429a6d0f5a9acb88df3b129"
Binary file