ghost 5.115.0 → 5.115.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 (197) hide show
  1. package/components/tryghost-adapter-cache-redis-5.115.1.tgz +0 -0
  2. package/components/tryghost-adapter-manager-5.115.1.tgz +0 -0
  3. package/components/{tryghost-announcement-bar-settings-5.115.0.tgz → tryghost-announcement-bar-settings-5.115.1.tgz} +0 -0
  4. package/components/{tryghost-api-framework-5.115.0.tgz → tryghost-api-framework-5.115.1.tgz} +0 -0
  5. package/components/tryghost-constants-5.115.1.tgz +0 -0
  6. package/components/tryghost-custom-fonts-5.115.1.tgz +0 -0
  7. package/components/{tryghost-custom-theme-settings-service-5.115.0.tgz → tryghost-custom-theme-settings-service-5.115.1.tgz} +0 -0
  8. package/components/{tryghost-data-generator-5.115.0.tgz → tryghost-data-generator-5.115.1.tgz} +0 -0
  9. package/components/tryghost-domain-events-5.115.1.tgz +0 -0
  10. package/components/tryghost-donations-5.115.1.tgz +0 -0
  11. package/components/{tryghost-email-addresses-5.115.0.tgz → tryghost-email-addresses-5.115.1.tgz} +0 -0
  12. package/components/{tryghost-email-content-generator-5.115.0.tgz → tryghost-email-content-generator-5.115.1.tgz} +0 -0
  13. package/components/{tryghost-email-events-5.115.0.tgz → tryghost-email-events-5.115.1.tgz} +0 -0
  14. package/components/tryghost-email-service-5.115.1.tgz +0 -0
  15. package/components/{tryghost-email-suppression-list-5.115.0.tgz → tryghost-email-suppression-list-5.115.1.tgz} +0 -0
  16. package/components/{tryghost-express-dynamic-redirects-5.115.0.tgz → tryghost-express-dynamic-redirects-5.115.1.tgz} +0 -0
  17. package/components/tryghost-ghost-5.115.1.tgz +0 -0
  18. package/components/tryghost-html-to-plaintext-5.115.1.tgz +0 -0
  19. package/components/tryghost-i18n-5.115.1.tgz +0 -0
  20. package/components/{tryghost-importer-handler-content-files-5.115.0.tgz → tryghost-importer-handler-content-files-5.115.1.tgz} +0 -0
  21. package/components/tryghost-in-memory-repository-5.115.1.tgz +0 -0
  22. package/components/{tryghost-job-manager-5.115.0.tgz → tryghost-job-manager-5.115.1.tgz} +0 -0
  23. package/components/{tryghost-link-redirects-5.115.0.tgz → tryghost-link-redirects-5.115.1.tgz} +0 -0
  24. package/components/tryghost-link-replacer-5.115.1.tgz +0 -0
  25. package/components/{tryghost-magic-link-5.115.0.tgz → tryghost-magic-link-5.115.1.tgz} +0 -0
  26. package/components/{tryghost-mailgun-client-5.115.0.tgz → tryghost-mailgun-client-5.115.1.tgz} +0 -0
  27. package/components/tryghost-member-attribution-5.115.1.tgz +0 -0
  28. package/components/{tryghost-member-events-5.115.0.tgz → tryghost-member-events-5.115.1.tgz} +0 -0
  29. package/components/{tryghost-members-api-5.115.0.tgz → tryghost-members-api-5.115.1.tgz} +0 -0
  30. package/components/{tryghost-members-csv-5.115.0.tgz → tryghost-members-csv-5.115.1.tgz} +0 -0
  31. package/components/{tryghost-members-offers-5.115.0.tgz → tryghost-members-offers-5.115.1.tgz} +0 -0
  32. package/components/{tryghost-members-payments-5.115.0.tgz → tryghost-members-payments-5.115.1.tgz} +0 -0
  33. package/components/{tryghost-milestones-5.115.0.tgz → tryghost-milestones-5.115.1.tgz} +0 -0
  34. package/components/{tryghost-minifier-5.115.0.tgz → tryghost-minifier-5.115.1.tgz} +0 -0
  35. package/components/{tryghost-mw-error-handler-5.115.0.tgz → tryghost-mw-error-handler-5.115.1.tgz} +0 -0
  36. package/components/{tryghost-mw-version-match-5.115.0.tgz → tryghost-mw-version-match-5.115.1.tgz} +0 -0
  37. package/components/tryghost-mw-vhost-5.115.1.tgz +0 -0
  38. package/components/tryghost-post-events-5.115.1.tgz +0 -0
  39. package/components/{tryghost-post-revisions-5.115.0.tgz → tryghost-post-revisions-5.115.1.tgz} +0 -0
  40. package/components/{tryghost-posts-service-5.115.0.tgz → tryghost-posts-service-5.115.1.tgz} +0 -0
  41. package/components/{tryghost-prometheus-metrics-5.115.0.tgz → tryghost-prometheus-metrics-5.115.1.tgz} +0 -0
  42. package/components/tryghost-recommendations-5.115.1.tgz +0 -0
  43. package/components/{tryghost-security-5.115.0.tgz → tryghost-security-5.115.1.tgz} +0 -0
  44. package/components/{tryghost-slack-notifications-5.115.0.tgz → tryghost-slack-notifications-5.115.1.tgz} +0 -0
  45. package/components/{tryghost-tiers-5.115.0.tgz → tryghost-tiers-5.115.1.tgz} +0 -0
  46. package/components/tryghost-webmentions-5.115.1.tgz +0 -0
  47. package/content/themes/casper/LICENSE +1 -1
  48. package/content/themes/casper/README.md +1 -1
  49. package/content/themes/source/LICENSE +1 -1
  50. package/content/themes/source/README.md +1 -1
  51. package/content/themes/source/assets/built/screen.css +1 -1
  52. package/content/themes/source/assets/built/screen.css.map +1 -1
  53. package/content/themes/source/assets/css/screen.css +11 -6
  54. package/content/themes/source/partials/feature-image.hbs +2 -2
  55. package/core/boot.js +3 -1
  56. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +23497 -23041
  57. package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +1 -1
  58. package/core/built/admin/assets/admin-x-demo/{index-0040480a.mjs → index-15df2af5.mjs} +4 -3
  59. package/core/built/admin/assets/admin-x-demo/{modals-fb35c86c.mjs → modals-8ca61d78.mjs} +67 -65
  60. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-806ef39c.mjs → CodeEditorView-d2e6872f.mjs} +2 -2
  61. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  62. package/core/built/admin/assets/admin-x-settings/{index-376f847c.mjs → index-8e8821e5.mjs} +2 -2
  63. package/core/built/admin/assets/admin-x-settings/{index-8fa19303.mjs → index-f5cb3db3.mjs} +3104 -3094
  64. package/core/built/admin/assets/admin-x-settings/{modals-36775d71.mjs → modals-e8ae4d46.mjs} +3 -3
  65. package/core/built/admin/assets/{chunk.524.31419fdf6fb3859ecc1e.js → chunk.524.2439684964c164c598ab.js} +6 -6
  66. package/core/built/admin/assets/{chunk.582.08c816d5e4ab766486a7.js → chunk.582.bf5a2bbb2c4eb69ef1e7.js} +10 -10
  67. package/core/built/admin/assets/ghost-327b17ea23cb8c89bd7e6a51e18e8506.css +1 -0
  68. package/core/built/admin/assets/ghost-dark-f30a597ac19632a118939492591c531b.css +1 -0
  69. package/core/built/admin/assets/{ghost-938b3d9c29e3564a53a22f8c8f82d351.js → ghost-df7b9558260aa27d18b195ee895b487d.js} +181 -159
  70. package/core/built/admin/assets/stats/stats.js +11824 -0
  71. package/core/built/admin/index.html +4 -4
  72. package/core/frontend/helpers/ghost_head.js +3 -1
  73. package/core/frontend/src/cards/css/cta.css +1 -1
  74. package/core/server/api/endpoints/slugs.js +6 -2
  75. package/core/server/data/importer/import-manager.js +2 -2
  76. package/core/server/data/importer/importers/importer-revue.js +128 -0
  77. package/core/server/data/importer/importers/json-to-html.js +107 -0
  78. package/core/server/data/migrations/utils/tables.js +2 -4
  79. package/core/server/lib/bootstrap-socket.js +87 -0
  80. package/core/server/lib/package-json/index.js +1 -0
  81. package/core/server/lib/package-json/package-json.js +160 -0
  82. package/core/server/lib/package-json/parse.js +57 -0
  83. package/core/server/models/base/plugins/actions.js +44 -31
  84. package/core/server/models/base/plugins/generate-slug.js +6 -0
  85. package/core/server/notify.js +1 -1
  86. package/core/server/services/activitypub/ActivityPubService.ts +1 -1
  87. package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +99 -0
  88. package/core/server/services/api-version-compatibility/VersionNotificationsDataService.js +80 -0
  89. package/core/server/services/api-version-compatibility/extract-api-key.js +57 -0
  90. package/core/server/services/api-version-compatibility/index.js +2 -2
  91. package/core/server/services/api-version-compatibility/mw-api-version-mismatch.js +31 -0
  92. package/core/server/services/audience-feedback/AudienceFeedbackController.js +85 -0
  93. package/core/server/services/audience-feedback/AudienceFeedbackService.js +34 -0
  94. package/core/server/services/audience-feedback/Feedback.js +35 -0
  95. package/core/server/services/audience-feedback/index.js +4 -2
  96. package/core/server/services/auth/session/emails/signin.js +168 -0
  97. package/core/server/services/auth/session/index.js +2 -2
  98. package/core/server/services/auth/session/session-from-token.js +69 -0
  99. package/core/server/services/auth/session/session-service.js +364 -0
  100. package/core/server/services/email-analytics/EmailAnalyticsProviderMailgun.js +62 -0
  101. package/core/server/services/email-analytics/EmailAnalyticsService.js +552 -0
  102. package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +3 -3
  103. package/core/server/services/email-analytics/EventProcessingResult.js +66 -0
  104. package/core/server/services/explore-ping/ExplorePingService.js +106 -0
  105. package/core/server/services/explore-ping/index.js +31 -0
  106. package/core/server/services/identity-tokens/IdentityTokenService.js +30 -0
  107. package/core/server/services/identity-tokens/IdentityTokenService.ts +28 -0
  108. package/core/server/services/identity-tokens/IdentityTokenServiceWrapper.js +1 -1
  109. package/core/server/services/invitations/accept.js +5 -2
  110. package/core/server/services/mail-events/BookshelfMailEventRepository.js +2 -2
  111. package/core/server/services/mail-events/InMemoryMailEventRepository.js +10 -0
  112. package/core/server/services/mail-events/InMemoryMailEventRepository.ts +8 -0
  113. package/core/server/services/mail-events/MailEvent.js +20 -0
  114. package/core/server/services/mail-events/MailEvent.ts +10 -0
  115. package/core/server/services/mail-events/MailEventRepository.js +2 -0
  116. package/core/server/services/mail-events/MailEventRepository.ts +5 -0
  117. package/core/server/services/mail-events/MailEventService.js +124 -0
  118. package/core/server/services/mail-events/MailEventService.ts +169 -0
  119. package/core/server/services/mail-events/index.js +1 -1
  120. package/core/server/services/mail-events/libraries.d.ts +2 -0
  121. package/core/server/services/members/CaptchaService.js +80 -0
  122. package/core/server/services/members/api.js +1 -1
  123. package/core/server/services/members/importer/MembersCSVImporter.js +464 -0
  124. package/core/server/services/members/importer/MembersCSVImporterStripeUtils.js +194 -0
  125. package/core/server/services/members/importer/email-template.js +182 -0
  126. package/core/server/services/members/importer/index.js +30 -0
  127. package/core/server/services/members/members-ssr.js +333 -0
  128. package/core/server/services/members/service.js +2 -2
  129. package/core/server/services/posts/stats/PostStats.js +13 -0
  130. package/core/server/services/route-settings/SettingsPathManager.js +47 -0
  131. package/core/server/services/route-settings/index.js +1 -1
  132. package/core/server/services/stripe/README.md +63 -0
  133. package/core/server/services/stripe/StripeAPI.js +931 -0
  134. package/core/server/services/stripe/StripeMigrations.js +613 -0
  135. package/core/server/services/stripe/StripeService.js +175 -0
  136. package/core/server/services/stripe/WebhookController.js +100 -0
  137. package/core/server/services/stripe/WebhookManager.js +175 -0
  138. package/core/server/services/stripe/events/StripeLiveDisabledEvent.js +23 -0
  139. package/core/server/services/stripe/events/StripeLiveEnabledEvent.js +23 -0
  140. package/core/server/services/stripe/events/index.js +4 -0
  141. package/core/server/services/stripe/service.js +1 -1
  142. package/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js +255 -0
  143. package/core/server/services/stripe/services/webhook/InvoiceEventService.js +70 -0
  144. package/core/server/services/stripe/services/webhook/SubscriptionEventService.js +54 -0
  145. package/core/server/services/themes/loader.js +1 -1
  146. package/core/server/services/themes/to-json.js +1 -1
  147. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  148. package/core/server/web/shared/middleware/cache-control.js +51 -0
  149. package/core/server/web/shared/middleware/index.js +1 -1
  150. package/core/server/web/well-known.js +1 -1
  151. package/core/shared/labs.js +3 -1
  152. package/core/shared/settings-cache/CacheManager.js +64 -6
  153. package/package.json +103 -134
  154. package/tsconfig.tsbuildinfo +1 -1
  155. package/yarn.lock +7 -93
  156. package/components/tryghost-adapter-cache-redis-5.115.0.tgz +0 -0
  157. package/components/tryghost-adapter-manager-5.115.0.tgz +0 -0
  158. package/components/tryghost-api-version-compatibility-service-5.115.0.tgz +0 -0
  159. package/components/tryghost-audience-feedback-5.115.0.tgz +0 -0
  160. package/components/tryghost-bookshelf-repository-5.115.0.tgz +0 -0
  161. package/components/tryghost-bootstrap-socket-5.115.0.tgz +0 -0
  162. package/components/tryghost-captcha-service-5.115.0.tgz +0 -0
  163. package/components/tryghost-constants-5.115.0.tgz +0 -0
  164. package/components/tryghost-custom-fonts-5.115.0.tgz +0 -0
  165. package/components/tryghost-domain-events-5.115.0.tgz +0 -0
  166. package/components/tryghost-donations-5.115.0.tgz +0 -0
  167. package/components/tryghost-email-analytics-provider-mailgun-5.115.0.tgz +0 -0
  168. package/components/tryghost-email-analytics-service-5.115.0.tgz +0 -0
  169. package/components/tryghost-email-service-5.115.0.tgz +0 -0
  170. package/components/tryghost-extract-api-key-5.115.0.tgz +0 -0
  171. package/components/tryghost-ghost-5.115.0.tgz +0 -0
  172. package/components/tryghost-html-to-plaintext-5.115.0.tgz +0 -0
  173. package/components/tryghost-i18n-5.115.0.tgz +0 -0
  174. package/components/tryghost-identity-token-service-5.115.0.tgz +0 -0
  175. package/components/tryghost-importer-revue-5.115.0.tgz +0 -0
  176. package/components/tryghost-in-memory-repository-5.115.0.tgz +0 -0
  177. package/components/tryghost-link-replacer-5.115.0.tgz +0 -0
  178. package/components/tryghost-mail-events-5.115.0.tgz +0 -0
  179. package/components/tryghost-member-attribution-5.115.0.tgz +0 -0
  180. package/components/tryghost-members-importer-5.115.0.tgz +0 -0
  181. package/components/tryghost-members-ssr-5.115.0.tgz +0 -0
  182. package/components/tryghost-members-stripe-service-5.115.0.tgz +0 -0
  183. package/components/tryghost-mw-api-version-mismatch-5.115.0.tgz +0 -0
  184. package/components/tryghost-mw-cache-control-5.115.0.tgz +0 -0
  185. package/components/tryghost-mw-session-from-token-5.115.0.tgz +0 -0
  186. package/components/tryghost-mw-update-user-last-seen-5.115.0.tgz +0 -0
  187. package/components/tryghost-mw-vhost-5.115.0.tgz +0 -0
  188. package/components/tryghost-package-json-5.115.0.tgz +0 -0
  189. package/components/tryghost-post-events-5.115.0.tgz +0 -0
  190. package/components/tryghost-recommendations-5.115.0.tgz +0 -0
  191. package/components/tryghost-referrers-5.115.0.tgz +0 -0
  192. package/components/tryghost-session-service-5.115.0.tgz +0 -0
  193. package/components/tryghost-settings-path-manager-5.115.0.tgz +0 -0
  194. package/components/tryghost-version-notifications-data-service-5.115.0.tgz +0 -0
  195. package/components/tryghost-webmentions-5.115.0.tgz +0 -0
  196. package/core/built/admin/assets/ghost-c2a7c4a1b76550c4219adb2ed4124ce0.css +0 -1
  197. package/core/built/admin/assets/ghost-dark-f91e4a479c6d38d94d5d1b14727871dc.css +0 -1
@@ -0,0 +1,552 @@
1
+ const EventProcessingResult = require('./EventProcessingResult');
2
+ const logging = require('@tryghost/logging');
3
+ const errors = require('@tryghost/errors');
4
+
5
+ /**
6
+ * @typedef {import('@tryghost/email-service').EmailEventProcessor} EmailEventProcessor
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} FetchData
11
+ * @property {boolean} running
12
+ * @property {('email-analytics-latest-others'|'email-analytics-missing'|'email-analytics-latest-opened'|'email-analytics-scheduled')} jobName Name of the job that is running
13
+ * @property {Date} [lastStarted] Date the last fetch started on
14
+ * @property {Date} [lastBegin] The begin time used during the last fetch
15
+ * @property {Date} [lastEventTimestamp]
16
+ * @property {boolean} [canceled] Set to quit the job early
17
+ */
18
+
19
+ /**
20
+ * @typedef {FetchData & {schedule?: {begin: Date, end: Date}}} FetchDataScheduled
21
+ */
22
+
23
+ /**
24
+ * @typedef {'delivered' | 'opened' | 'failed' | 'unsubscribed' | 'complained'} EmailAnalyticsEvent
25
+ */
26
+
27
+ const TRUST_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
28
+ 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
+
30
+ module.exports = class EmailAnalyticsService {
31
+ config;
32
+ settings;
33
+ queries;
34
+ eventProcessor;
35
+ providers;
36
+
37
+ /**
38
+ * @type {FetchData}
39
+ */
40
+ #fetchLatestNonOpenedData = {
41
+ running: false,
42
+ jobName: 'email-analytics-latest-others'
43
+ };
44
+
45
+ /**
46
+ * @type {FetchData}
47
+ */
48
+ #fetchMissingData = {
49
+ running: false,
50
+ jobName: 'email-analytics-missing'
51
+ };
52
+
53
+ /**
54
+ * @type {FetchData}
55
+ */
56
+ #fetchLatestOpenedData = {
57
+ running: false,
58
+ jobName: 'email-analytics-latest-opened'
59
+ };
60
+
61
+ /**
62
+ * @type {FetchDataScheduled}
63
+ */
64
+ #fetchScheduledData = {
65
+ running: false,
66
+ jobName: 'email-analytics-scheduled'
67
+ };
68
+
69
+ /**
70
+ * @param {object} dependencies
71
+ * @param {object} dependencies.config
72
+ * @param {object} dependencies.settings
73
+ * @param {object} dependencies.queries
74
+ * @param {EmailEventProcessor} dependencies.eventProcessor
75
+ * @param {object} dependencies.providers
76
+ * @param {import('@tryghost/domain-events')} dependencies.domainEvents
77
+ * @param {import('@tryghost/prometheus-metrics')} dependencies.prometheusClient
78
+ */
79
+ constructor({config, settings, queries, eventProcessor, providers, domainEvents, prometheusClient}) {
80
+ this.config = config;
81
+ this.settings = settings;
82
+ this.queries = queries;
83
+ this.eventProcessor = eventProcessor;
84
+ this.providers = providers;
85
+ this.domainEvents = domainEvents;
86
+ this.prometheusClient = prometheusClient;
87
+
88
+ if (prometheusClient) {
89
+ // @ts-expect-error
90
+ prometheusClient.registerCounter({name: 'email_analytics_aggregate_member_stats_count', help: 'Count of member stats aggregations'});
91
+ }
92
+ }
93
+
94
+ getStatus() {
95
+ return {
96
+ latest: this.#fetchLatestNonOpenedData,
97
+ missing: this.#fetchMissingData,
98
+ scheduled: this.#fetchScheduledData,
99
+ latestOpened: this.#fetchLatestOpenedData
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Returns the timestamp of the last non-opened event we processed. Defaults to now minus 30 minutes if we have no data yet.
105
+ */
106
+ async getLastNonOpenedEventTimestamp() {
107
+ return this.#fetchLatestNonOpenedData?.lastEventTimestamp ?? (await this.queries.getLastEventTimestamp(this.#fetchLatestNonOpenedData.jobName,['delivered','failed'])) ?? new Date(Date.now() - TRUST_THRESHOLD_MS);
108
+ }
109
+
110
+ /**
111
+ * Returns the timestamp of the last opened event we processed. Defaults to now minus 30 minutes if we have no data yet.
112
+ */
113
+ async getLastOpenedEventTimestamp() {
114
+ return this.#fetchLatestOpenedData?.lastEventTimestamp ?? (await this.queries.getLastEventTimestamp(this.#fetchLatestOpenedData.jobName,['opened'])) ?? new Date(Date.now() - TRUST_THRESHOLD_MS);
115
+ }
116
+
117
+ /**
118
+ * Returns the timestamp of the last missing event we processed. Defaults to now minus 2h if we have no data yet.
119
+ */
120
+ async getLastMissingEventTimestamp() {
121
+ return this.#fetchMissingData?.lastEventTimestamp ?? (await this.queries.getLastJobRunTimestamp(this.#fetchMissingData.jobName)) ?? new Date(Date.now() - TRUST_THRESHOLD_MS * 4);
122
+ }
123
+
124
+ /**
125
+ * Fetches the latest opened events.
126
+ * @param {Object} options - The options for fetching events.
127
+ * @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch.
128
+ * @returns {Promise<number>} The total number of events fetched.
129
+ */
130
+ async fetchLatestOpenedEvents({maxEvents = Infinity} = {}) {
131
+ const begin = await this.getLastOpenedEventTimestamp();
132
+ const end = new Date(Date.now() - FETCH_LATEST_END_MARGIN_MS); // Always stop at x minutes ago to give Mailgun a bit more time to stabilize storage
133
+
134
+ if (end <= begin) {
135
+ // Skip for now
136
+ logging.info('[EmailAnalytics] Skipping fetchLatestOpenedEvents because end (' + end + ') is before begin (' + begin + ')');
137
+ return 0;
138
+ }
139
+
140
+ return await this.#fetchEvents(this.#fetchLatestOpenedData, {begin, end, maxEvents, eventTypes: ['opened']});
141
+ }
142
+
143
+ /**
144
+ * Fetches the latest non-opened events.
145
+ * @param {Object} options - The options for fetching events.
146
+ * @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch.
147
+ * @returns {Promise<number>} The total number of events fetched.
148
+ */
149
+ async fetchLatestNonOpenedEvents({maxEvents = Infinity} = {}) {
150
+ const begin = await this.getLastNonOpenedEventTimestamp();
151
+ const end = new Date(Date.now() - FETCH_LATEST_END_MARGIN_MS); // Always stop at x minutes ago to give Mailgun a bit more time to stabilize storage
152
+
153
+ if (end <= begin) {
154
+ // Skip for now
155
+ logging.info('[EmailAnalytics] Skipping fetchLatestNonOpenedEvents because end (' + end + ') is before begin (' + begin + ')');
156
+ return 0;
157
+ }
158
+
159
+ return await this.#fetchEvents(this.#fetchLatestNonOpenedData, {begin, end, maxEvents, eventTypes: ['delivered', 'failed', 'unsubscribed', 'complained']});
160
+ }
161
+
162
+ /**
163
+ * 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
+ * @param {object} options
165
+ * @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.
166
+ */
167
+ async fetchMissing({maxEvents = Infinity} = {}) {
168
+ const begin = await this.getLastMissingEventTimestamp();
169
+
170
+ // Always stop at the earlier of the time the fetchLatest started fetching on or 30 minutes ago
171
+ const end = new Date(
172
+ Math.min(
173
+ Date.now() - TRUST_THRESHOLD_MS,
174
+ this.#fetchLatestNonOpenedData?.lastBegin?.getTime() || Date.now() // Fallback to now if the previous job didn't run, for whatever reason, prevents catastrophic error
175
+ )
176
+ );
177
+
178
+ if (end <= begin) {
179
+ // Skip for now
180
+ logging.info('[EmailAnalytics] Skipping fetchMissing because end (' + end + ') is before begin (' + begin + ')');
181
+ return 0;
182
+ }
183
+
184
+ return await this.#fetchEvents(this.#fetchMissingData, {begin, end, maxEvents});
185
+ }
186
+
187
+ /**
188
+ * Schedule a new fetch for email analytics events.
189
+ * @param {Object} options - The options for scheduling the fetch.
190
+ * @param {Date} options.begin - The start date for the scheduled fetch.
191
+ * @param {Date} options.end - The end date for the scheduled fetch.
192
+ * @throws {errors.ValidationError} Throws an error if a fetch is already in progress.
193
+ */
194
+ schedule({begin, end}) {
195
+ if (this.#fetchScheduledData && this.#fetchScheduledData.running) {
196
+ throw new errors.ValidationError({
197
+ message: 'Already fetching scheduled events. Wait for it to finish before scheduling a new one.'
198
+ });
199
+ }
200
+ logging.info('[EmailAnalytics] Scheduling fetch from ' + begin.toISOString() + ' until ' + end.toISOString());
201
+ this.#fetchScheduledData = {
202
+ running: false,
203
+ jobName: 'email-analytics-scheduled',
204
+ schedule: {
205
+ begin,
206
+ end
207
+ }
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Cancels the scheduled fetch of email analytics events.
213
+ * If a fetch is currently running, it marks it for cancellation.
214
+ * If no fetch is running, it clears the scheduled fetch data.
215
+ * @method cancelScheduled
216
+ */
217
+ cancelScheduled() {
218
+ if (this.#fetchScheduledData) {
219
+ if (this.#fetchScheduledData.running) {
220
+ // Cancel the running fetch
221
+ this.#fetchScheduledData.canceled = true;
222
+ } else {
223
+ this.#fetchScheduledData = {
224
+ running: false,
225
+ jobName: 'email-analytics-scheduled'
226
+ };
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Continues fetching the scheduled events (does not start one). Resets the scheduled event when received 0 events.
233
+ * @method fetchScheduled
234
+ * @param {Object} [options] - The options for fetching scheduled events.
235
+ * @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch.
236
+ * @returns {Promise<number>} The number of events fetched.
237
+ */
238
+ async fetchScheduled({maxEvents = Infinity} = {}) {
239
+ if (!this.#fetchScheduledData || !this.#fetchScheduledData.schedule) {
240
+ // Nothing scheduled
241
+ return 0;
242
+ }
243
+
244
+ if (this.#fetchScheduledData.canceled) {
245
+ // Skip for now
246
+ this.#fetchScheduledData = null;
247
+ return 0;
248
+ }
249
+
250
+ let begin = this.#fetchScheduledData.schedule.begin;
251
+ const end = this.#fetchScheduledData.schedule.end;
252
+
253
+ if (this.#fetchScheduledData.lastEventTimestamp && this.#fetchScheduledData.lastEventTimestamp > begin) {
254
+ // Continue where we left of
255
+ begin = this.#fetchScheduledData.lastEventTimestamp;
256
+ }
257
+
258
+ if (end <= begin) {
259
+ // Skip for now
260
+ logging.info('[EmailAnalytics] Ending fetchScheduled because end is before begin');
261
+ this.#fetchScheduledData = {
262
+ running: false,
263
+ jobName: 'email-analytics-scheduled'
264
+ };
265
+ return 0;
266
+ }
267
+
268
+ const count = await this.#fetchEvents(this.#fetchScheduledData, {begin, end, maxEvents});
269
+ if (count === 0 || this.#fetchScheduledData.canceled) {
270
+ // Reset the scheduled fetch
271
+ this.#fetchScheduledData = {
272
+ running: false,
273
+ jobName: 'email-analytics-scheduled'
274
+ };
275
+ }
276
+
277
+ this.queries.setJobTimestamp(this.#fetchScheduledData.jobName, 'finished', this.#fetchScheduledData.lastEventTimestamp);
278
+ return count;
279
+ }
280
+ /**
281
+ * Start fetching analytics and store the data of the progress inside fetchData
282
+ * @param {FetchData} fetchData - Object to store the progress of the fetch operation
283
+ * @param {object} options - Options for fetching events
284
+ * @param {Date} options.begin - Start date for fetching events
285
+ * @param {Date} options.end - End date for fetching events
286
+ * @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
+ * @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
289
+ */
290
+ async #fetchEvents(fetchData, {begin, end, maxEvents = Infinity, eventTypes = null}) {
291
+ // 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
+ // Store that we started fetching
295
+ fetchData.running = true;
296
+ fetchData.lastStarted = new Date();
297
+ fetchData.lastBegin = begin;
298
+ this.queries.setJobTimestamp(fetchData.jobName, 'started', begin);
299
+
300
+ let lastAggregation = Date.now();
301
+ let eventCount = 0;
302
+ const includeOpenedEvents = eventTypes?.includes('opened') ?? false;
303
+
304
+ // We keep the processing result here, so we also have a result in case of failures
305
+ let processingResult = new EventProcessingResult();
306
+ let error = null;
307
+
308
+ /**
309
+ * Process a batch of events
310
+ * @param {Array<Object>} events - Array of event objects to process
311
+ * @param {EventProcessingResult} processingResult - Object to store the processing results
312
+ * @param {FetchData} fetchData - Object containing fetch operation data
313
+ * @returns {Promise<void>}
314
+ */
315
+ const processBatch = async (events) => {
316
+ // Even if the fetching is interrupted because of an error, we still store the last event timestamp
317
+ await this.processEventBatch(events, processingResult, fetchData);
318
+ eventCount += events.length;
319
+
320
+ // Every 5 minutes or 5000 members we do an aggregation and clear the processingResult
321
+ // Otherwise we need to loop a lot of members afterwards, and this takes too long without updating the stat counts in between
322
+ if ((Date.now() - lastAggregation > 5 * 60 * 1000 || processingResult.memberIds.length > 5000) && eventCount > 0) {
323
+ // Aggregate and clear the processingResult
324
+ // We do this here because otherwise it could take a long time before the new events are visible in the stats
325
+ try {
326
+ await this.aggregateStats(processingResult, includeOpenedEvents);
327
+ lastAggregation = Date.now();
328
+ processingResult = new EventProcessingResult();
329
+ } catch (err) {
330
+ logging.error('[EmailAnalytics] Error while aggregating stats');
331
+ logging.error(err);
332
+ }
333
+ }
334
+
335
+ if (fetchData.canceled) {
336
+ throw new errors.InternalServerError({
337
+ message: 'Fetching canceled'
338
+ });
339
+ }
340
+ };
341
+
342
+ try {
343
+ for (const provider of this.providers) {
344
+ await provider.fetchLatest(processBatch, {begin, end, maxEvents, events: eventTypes});
345
+ }
346
+
347
+ logging.info('[EmailAnalytics] Fetching finished');
348
+ } catch (err) {
349
+ if (err.message !== 'Fetching canceled') {
350
+ logging.error('[EmailAnalytics] Error while fetching');
351
+ logging.error(err);
352
+ error = err;
353
+ } else {
354
+ logging.error('[EmailAnalytics] Canceled fetching');
355
+ }
356
+ }
357
+
358
+ if (processingResult.memberIds.length > 0 || processingResult.emailIds.length > 0) {
359
+ try {
360
+ await this.aggregateStats(processingResult, includeOpenedEvents);
361
+ } catch (err) {
362
+ logging.error('[EmailAnalytics] Error while aggregating stats');
363
+ logging.error(err);
364
+
365
+ if (!error) {
366
+ error = err;
367
+ }
368
+ }
369
+ }
370
+
371
+ // Small trick: if reached the end of new events, we are going to keep
372
+ // fetching the same events because 'begin' won't change
373
+ // So if we didn't have errors while fetching, and total events < maxEvents, increase lastEventTimestamp with one second
374
+ 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
+ // set the data on the db so we can store it for fetching after reboot
377
+ await this.queries.setJobTimestamp(fetchData.jobName, 'finished', new Date(fetchData.lastEventTimestamp.getTime()));
378
+ // increment and store in local memory
379
+ fetchData.lastEventTimestamp = new Date(fetchData.lastEventTimestamp.getTime() + 1000);
380
+ } else {
381
+ logging.info('[EmailAnalytics] No new events found');
382
+ // set job status to finished
383
+ await this.queries.setJobStatus(fetchData.jobName, 'finished');
384
+ }
385
+
386
+ fetchData.running = false;
387
+
388
+ if (error) {
389
+ throw error;
390
+ }
391
+ return eventCount;
392
+ }
393
+
394
+ /**
395
+ * Process a batch of email analytics events.
396
+ * @param {any[]} events - An array of email analytics events to process.
397
+ * @param {Object} result - The result object to merge batch processing results into.
398
+ * @param {FetchData} fetchData - Data related to the current fetch operation.
399
+ * @returns {Promise<void>}
400
+ */
401
+ async processEventBatch(events, result, fetchData) {
402
+ for (const event of events) {
403
+ const batchResult = await this.processEvent(event);
404
+
405
+ // Save last event timestamp
406
+ if (!fetchData.lastEventTimestamp || (event.timestamp && event.timestamp > fetchData.lastEventTimestamp)) {
407
+ fetchData.lastEventTimestamp = event.timestamp; // don't need to keep db in sync; it'll fall back to last completed timestamp anyways
408
+ }
409
+
410
+ result.merge(batchResult);
411
+ }
412
+ }
413
+
414
+ /**
415
+ *
416
+ * @param {{id: string, type: any; severity: any; recipientEmail: any; emailId?: string; providerId: string; timestamp: Date; error: {code: number; message: string; enhandedCode: string|number} | null}} event
417
+ * @returns {Promise<EventProcessingResult>}
418
+ */
419
+ async processEvent(event) {
420
+ if (event.type === 'delivered') {
421
+ const recipient = await this.eventProcessor.handleDelivered({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
422
+
423
+ if (recipient) {
424
+ return new EventProcessingResult({
425
+ delivered: 1,
426
+ emailIds: [recipient.emailId],
427
+ memberIds: [recipient.memberId]
428
+ });
429
+ }
430
+
431
+ return new EventProcessingResult({unprocessable: 1});
432
+ }
433
+
434
+ if (event.type === 'opened') {
435
+ const recipient = await this.eventProcessor.handleOpened({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
436
+
437
+ if (recipient) {
438
+ return new EventProcessingResult({
439
+ opened: 1,
440
+ emailIds: [recipient.emailId],
441
+ memberIds: [recipient.memberId]
442
+ });
443
+ }
444
+
445
+ return new EventProcessingResult({unprocessable: 1});
446
+ }
447
+
448
+ if (event.type === 'failed') {
449
+ if (event.severity === 'permanent') {
450
+ const recipient = await this.eventProcessor.handlePermanentFailed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, {id: event.id, timestamp: event.timestamp, error: event.error});
451
+
452
+ if (recipient) {
453
+ return new EventProcessingResult({
454
+ permanentFailed: 1,
455
+ emailIds: [recipient.emailId],
456
+ memberIds: [recipient.memberId]
457
+ });
458
+ }
459
+
460
+ return new EventProcessingResult({unprocessable: 1});
461
+ } else {
462
+ const recipient = await this.eventProcessor.handleTemporaryFailed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, {id: event.id, timestamp: event.timestamp, error: event.error});
463
+
464
+ if (recipient) {
465
+ return new EventProcessingResult({
466
+ temporaryFailed: 1,
467
+ emailIds: [recipient.emailId],
468
+ memberIds: [recipient.memberId]
469
+ });
470
+ }
471
+
472
+ return new EventProcessingResult({unprocessable: 1});
473
+ }
474
+ }
475
+
476
+ if (event.type === 'unsubscribed') {
477
+ const recipient = await this.eventProcessor.handleUnsubscribed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
478
+
479
+ if (recipient) {
480
+ return new EventProcessingResult({
481
+ unsubscribed: 1,
482
+ emailIds: [recipient.emailId],
483
+ memberIds: [recipient.memberId]
484
+ });
485
+ }
486
+
487
+ return new EventProcessingResult({unprocessable: 1});
488
+ }
489
+
490
+ if (event.type === 'complained') {
491
+ const recipient = await this.eventProcessor.handleComplained({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp);
492
+
493
+ if (recipient) {
494
+ return new EventProcessingResult({
495
+ complained: 1,
496
+ emailIds: [recipient.emailId],
497
+ memberIds: [recipient.memberId]
498
+ });
499
+ }
500
+
501
+ return new EventProcessingResult({unprocessable: 1});
502
+ }
503
+
504
+ return new EventProcessingResult({unhandled: 1});
505
+ }
506
+
507
+ /**
508
+ * @param {{emailIds?: string[], memberIds?: string[]}} stats
509
+ * @param {boolean} includeOpenedEvents
510
+ */
511
+ async aggregateStats({emailIds = [], memberIds = []}, includeOpenedEvents = true) {
512
+ let startTime = Date.now();
513
+ logging.info(`[EmailAnalytics] Aggregating for ${emailIds.length} emails`);
514
+
515
+ for (const emailId of emailIds) {
516
+ await this.aggregateEmailStats(emailId, includeOpenedEvents);
517
+ }
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
+
524
+ // @ts-expect-error
525
+ const memberMetric = this.prometheusClient?.getMetric('email_analytics_aggregate_member_stats_count');
526
+ for (const memberId of memberIds) {
527
+ await this.aggregateMemberStats(memberId);
528
+ memberMetric?.inc();
529
+ }
530
+ endTime = Date.now() - startTime;
531
+ logging.info(`[EmailAnalytics] Aggregating for ${memberIds.length} members took ${endTime}ms`);
532
+ }
533
+
534
+ /**
535
+ * Aggregate email stats for a given email ID.
536
+ * @param {string} emailId - The ID of the email to aggregate stats for.
537
+ * @param {boolean} includeOpenedEvents - Whether to include opened events in the stats.
538
+ * @returns {Promise<void>}
539
+ */
540
+ async aggregateEmailStats(emailId, includeOpenedEvents) {
541
+ return this.queries.aggregateEmailStats(emailId, includeOpenedEvents);
542
+ }
543
+
544
+ /**
545
+ * Aggregate member stats for a given member ID.
546
+ * @param {string} memberId - The ID of the member to aggregate stats for.
547
+ * @returns {Promise<void>}
548
+ */
549
+ async aggregateMemberStats(memberId) {
550
+ return this.queries.aggregateMemberStats(memberId);
551
+ }
552
+ };
@@ -6,9 +6,9 @@ class EmailAnalyticsServiceWrapper {
6
6
  return;
7
7
  }
8
8
 
9
- const {EmailAnalyticsService} = require('@tryghost/email-analytics-service');
9
+ const EmailAnalyticsService = require('./EmailAnalyticsService');
10
10
  const {EmailEventStorage, EmailEventProcessor} = require('@tryghost/email-service');
11
- const MailgunProvider = require('@tryghost/email-analytics-provider-mailgun');
11
+ const MailgunProvider = require('./EmailAnalyticsProviderMailgun');
12
12
  const {EmailRecipientFailure, EmailSpamComplaintEvent, Email} = require('../../models');
13
13
  const StartEmailAnalyticsJobEvent = require('./events/StartEmailAnalyticsJobEvent');
14
14
  const domainEvents = require('@tryghost/domain-events');
@@ -142,7 +142,7 @@ class EmailAnalyticsServiceWrapper {
142
142
  this._restartFetch('scheduled backfill');
143
143
  return;
144
144
  }
145
-
145
+
146
146
  this.fetching = false;
147
147
  } catch (e) {
148
148
  logging.error(e, 'Error while fetching email analytics');
@@ -0,0 +1,66 @@
1
+ class EventProcessingResult {
2
+ /**
3
+ * @param {object} result
4
+ * @param {number} [result.delivered]
5
+ * @param {number} [result.opened]
6
+ * @param {number} [result.temporaryFailed]
7
+ * @param {number} [result.permanentFailed]
8
+ * @param {number} [result.unsubscribed]
9
+ * @param {number} [result.complained]
10
+ * @param {number} [result.unhandled]
11
+ * @param {number} [result.unprocessable]
12
+ * @param {number} [result.processingFailures]
13
+ * @param {string[]} [result.emailIds]
14
+ * @param {string[]} [result.memberIds]
15
+ */
16
+ constructor(result = {}) {
17
+ // counts
18
+ this.delivered = 0;
19
+ this.opened = 0;
20
+ this.temporaryFailed = 0;
21
+ this.permanentFailed = 0;
22
+ this.unsubscribed = 0;
23
+ this.complained = 0;
24
+ this.unhandled = 0;
25
+ this.unprocessable = 0;
26
+
27
+ // processing failures are counted separately in addition to event type counts
28
+ this.processingFailures = 0;
29
+
30
+ // ids seen whilst processing ready for passing to the stats aggregator
31
+ this.emailIds = [];
32
+ this.memberIds = [];
33
+
34
+ this.merge(result);
35
+ }
36
+
37
+ get totalEvents() {
38
+ return this.delivered
39
+ + this.opened
40
+ + this.temporaryFailed
41
+ + this.permanentFailed
42
+ + this.unsubscribed
43
+ + this.complained
44
+ + this.unhandled
45
+ + this.unprocessable;
46
+ }
47
+
48
+ merge(other = {}) {
49
+ this.delivered += other.delivered || 0;
50
+ this.opened += other.opened || 0;
51
+ this.temporaryFailed += other.temporaryFailed || 0;
52
+ this.permanentFailed += other.permanentFailed || 0;
53
+ this.unsubscribed += other.unsubscribed || 0;
54
+ this.complained += other.complained || 0;
55
+ this.unhandled += other.unhandled || 0;
56
+ this.unprocessable += other.unprocessable || 0;
57
+
58
+ this.processingFailures += other.processingFailures || 0;
59
+
60
+ // TODO: come up with a cleaner way to merge these without churning through Array and Set
61
+ this.emailIds = Array.from(new Set([...this.emailIds, ...(other.emailIds || [])])).filter(Boolean);
62
+ this.memberIds = Array.from(new Set([...this.memberIds, ...(other.memberIds || [])])).filter(Boolean);
63
+ }
64
+ }
65
+
66
+ module.exports = EventProcessingResult;