ghost 5.119.2 → 5.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/components/tryghost-i18n-5.120.0.tgz +0 -0
  2. package/core/boot.js +0 -2
  3. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +7555 -7216
  4. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-60ce658c.mjs → CodeEditorView-1c5b0683.mjs} +2 -2
  5. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  6. package/core/built/admin/assets/admin-x-settings/{index-8480baa8.mjs → index-14e518a7.mjs} +3 -3
  7. package/core/built/admin/assets/admin-x-settings/{index-a2648c61.mjs → index-fc9f985b.mjs} +2 -2
  8. package/core/built/admin/assets/admin-x-settings/{modals-6900c1d5.mjs → modals-15bc6a0f.mjs} +7192 -6656
  9. package/core/built/admin/assets/{chunk.137.c9bf40f01afeeadb4660.js → chunk.383.25fca2f09b4896656125.js} +76 -59
  10. package/core/built/admin/assets/chunk.524.1657b12c0ab25dd9fb79.js +28 -0
  11. package/core/built/admin/assets/{chunk.582.98a820cbc4bb65f2e685.js → chunk.582.09869b1f1a3cc0ab81f6.js} +19 -26
  12. package/core/built/admin/assets/{ghost-843572e9507d099162ae744d791daba1.js → ghost-b3b44421acca3b3eec76bfbb6ba0e81b.js} +3 -3
  13. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +12578 -12352
  14. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +423 -211
  15. package/core/built/admin/assets/posts/posts.js +13680 -13671
  16. package/core/built/admin/assets/stats/stats.js +16457 -16635
  17. package/core/built/admin/assets/{vendor-8f805740fee4db959a5b2119001a56b1.js → vendor-4ce6d282a2a00fe486a0951e0591da19.js} +11 -9
  18. package/core/built/admin/index.html +5 -5
  19. package/core/frontend/helpers/match.js +6 -0
  20. package/core/frontend/services/routing/ParentRouter.js +1 -1
  21. package/core/frontend/services/routing/controllers/email-post.js +0 -2
  22. package/core/frontend/services/routing/controllers/previews.js +0 -3
  23. package/core/frontend/web/middleware/frontend-caching.js +2 -2
  24. package/core/server/api/endpoints/authentication.js +37 -73
  25. package/core/server/api/endpoints/authors-public.js +8 -9
  26. package/core/server/api/endpoints/db.js +34 -35
  27. package/core/server/api/endpoints/emails.js +8 -10
  28. package/core/server/api/endpoints/integrations.js +20 -18
  29. package/core/server/api/endpoints/invites.js +8 -10
  30. package/core/server/api/endpoints/labels.js +19 -23
  31. package/core/server/api/endpoints/notifications.js +3 -4
  32. package/core/server/api/endpoints/pages-public.js +8 -10
  33. package/core/server/api/endpoints/pages.js +14 -18
  34. package/core/server/api/endpoints/posts-public.js +8 -10
  35. package/core/server/api/endpoints/posts.js +6 -8
  36. package/core/server/api/endpoints/previews.js +8 -10
  37. package/core/server/api/endpoints/redirects.js +7 -8
  38. package/core/server/api/endpoints/schedules.js +5 -7
  39. package/core/server/api/endpoints/slugs.js +7 -9
  40. package/core/server/api/endpoints/snippets.js +16 -20
  41. package/core/server/api/endpoints/tags-public.js +8 -10
  42. package/core/server/api/endpoints/tags.js +19 -23
  43. package/core/server/api/endpoints/themes.js +6 -8
  44. package/core/server/api/endpoints/users.js +31 -36
  45. package/core/server/api/endpoints/utils/permissions.js +10 -10
  46. package/core/server/api/endpoints/utils/serializers/output/roles.js +9 -10
  47. package/core/server/api/endpoints/utils/validators/input/images.js +43 -52
  48. package/core/server/api/endpoints/utils/validators/input/invites.js +6 -8
  49. package/core/server/api/endpoints/webhooks.js +38 -42
  50. package/core/server/data/migrations/versions/5.120/2025-05-07-14-57-38-add-newsletters-button-corners-column.js +8 -0
  51. package/core/server/data/migrations/versions/5.120/2025-05-13-17-36-56-add-newsletters-button-style-column.js +8 -0
  52. package/core/server/data/migrations/versions/5.120/2025-05-14-20-00-15-add-newsletters-setting-columns.js +22 -0
  53. package/core/server/data/schema/schema.js +6 -1
  54. package/core/server/lib/image/Gravatar.js +12 -13
  55. package/core/server/lib/lexical.js +3 -1
  56. package/core/server/models/newsletter.js +6 -1
  57. package/core/server/services/api-version-compatibility/index.js +1 -33
  58. package/core/server/services/auth/session/emails/signin.js +3 -3
  59. package/core/server/services/email-address/EmailAddressParser.js +52 -0
  60. package/core/server/services/email-address/EmailAddressParser.js.d.ts +13 -0
  61. package/core/server/services/email-address/EmailAddressService.js +142 -0
  62. package/core/server/services/email-address/EmailAddressService.ts +183 -0
  63. package/core/server/services/email-address/EmailAddressServiceWrapper.js +2 -4
  64. package/core/server/services/email-analytics/EmailAnalyticsService.js +1 -1
  65. package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +2 -1
  66. package/core/server/services/email-service/BatchSendingService.js +703 -0
  67. package/core/server/services/email-service/EmailBodyCache.js +20 -0
  68. package/core/server/services/email-service/EmailController.js +94 -0
  69. package/core/server/services/email-service/EmailEventProcessor.js +267 -0
  70. package/core/server/services/email-service/EmailEventStorage.js +187 -0
  71. package/core/server/services/email-service/EmailRenderer.js +1263 -0
  72. package/core/server/services/email-service/EmailSegmenter.js +74 -0
  73. package/core/server/services/email-service/EmailService.js +310 -0
  74. package/core/server/services/email-service/EmailServiceWrapper.js +9 -2
  75. package/core/server/services/email-service/MailgunEmailProvider.js +191 -0
  76. package/core/server/services/email-service/SendingService.js +173 -0
  77. package/core/server/services/email-service/email-templates/partials/feedback-button.hbs +7 -0
  78. package/core/server/services/email-service/email-templates/partials/latest-posts.hbs +39 -0
  79. package/core/server/services/email-service/email-templates/partials/paywall.hbs +20 -0
  80. package/core/server/services/email-service/email-templates/partials/styles.hbs +2348 -0
  81. package/core/server/services/email-service/email-templates/template.hbs +238 -0
  82. package/core/server/services/email-service/events/EmailBouncedEvent.js +63 -0
  83. package/core/server/services/email-service/events/EmailDeliveredEvent.js +49 -0
  84. package/core/server/services/email-service/events/EmailOpenedEvent.js +49 -0
  85. package/core/server/services/email-service/events/EmailTemporaryBouncedEvent.js +63 -0
  86. package/core/server/services/email-service/events/EmailUnsubscribedEvent.js +42 -0
  87. package/core/server/services/email-service/events/SpamComplaintEvent.js +42 -0
  88. package/core/server/services/email-service/helpers/register-helpers.js +59 -0
  89. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +2 -1
  90. package/core/server/services/explore-ping/index.js +2 -1
  91. package/core/server/services/mail/GhostMailer.js +1 -1
  92. package/core/server/services/media-inliner/ExternalMediaInliner.js +2 -1
  93. package/core/server/services/members/api.js +15 -15
  94. package/core/server/services/members/emails/signin.js +4 -4
  95. package/core/server/services/members/emails/signup-paid.js +3 -4
  96. package/core/server/services/members/emails/signup.js +3 -3
  97. package/core/server/services/members/emails/subscribe.js +3 -3
  98. package/core/server/services/members/members-api/controllers/RouterController.js +50 -36
  99. package/core/server/services/members/members-api/repositories/MemberRepository.js +92 -92
  100. package/core/server/services/members-events/LastSeenAtUpdater.js +1 -1
  101. package/core/server/services/settings-helpers/SettingsHelpers.js +1 -1
  102. package/core/server/services/staff/StaffServiceEmails.js +1 -1
  103. package/core/server/services/stats/PostsStatsService.js +28 -7
  104. package/core/server/web/api/app.js +0 -1
  105. package/core/server/web/api/endpoints/admin/app.js +0 -2
  106. package/core/server/web/api/endpoints/content/app.js +0 -2
  107. package/core/server/web/api/middleware/upload.js +2 -2
  108. package/core/shared/custom-theme-settings-cache/CustomThemeSettingsService.js +2 -1
  109. package/package.json +39 -97
  110. package/tsconfig.tsbuildinfo +1 -1
  111. package/yarn.lock +385 -517
  112. package/components/tryghost-api-framework-5.119.2.tgz +0 -0
  113. package/components/tryghost-custom-fonts-5.119.2.tgz +0 -0
  114. package/components/tryghost-domain-events-5.119.2.tgz +0 -0
  115. package/components/tryghost-email-addresses-5.119.2.tgz +0 -0
  116. package/components/tryghost-email-service-5.119.2.tgz +0 -0
  117. package/components/tryghost-html-to-plaintext-5.119.2.tgz +0 -0
  118. package/components/tryghost-i18n-5.119.2.tgz +0 -0
  119. package/components/tryghost-job-manager-5.119.2.tgz +0 -0
  120. package/components/tryghost-members-csv-5.119.2.tgz +0 -0
  121. package/components/tryghost-mw-error-handler-5.119.2.tgz +0 -0
  122. package/components/tryghost-mw-vhost-5.119.2.tgz +0 -0
  123. package/components/tryghost-prometheus-metrics-5.119.2.tgz +0 -0
  124. package/components/tryghost-security-5.119.2.tgz +0 -0
  125. package/core/built/admin/assets/chunk.524.b8545af3bb714bc4f820.js +0 -35
  126. package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +0 -99
  127. package/core/server/services/api-version-compatibility/VersionNotificationsDataService.js +0 -80
  128. package/core/server/services/api-version-compatibility/extract-api-key.js +0 -57
  129. package/core/server/services/api-version-compatibility/mw-api-version-mismatch.js +0 -31
  130. /package/core/built/admin/assets/{chunk.137.c9bf40f01afeeadb4660.js.LICENSE.txt → chunk.383.25fca2f09b4896656125.js.LICENSE.txt} +0 -0
@@ -0,0 +1,20 @@
1
+ /**
2
+ * This is a cache provider that lives very short in memory, there is no need for persistence.
3
+ * It is created when scheduling an email in the batch sending service, and is then passed to the sending service. The sending service
4
+ * can optionally use a passed cache provider to reuse the email body for each batch with the same segment.
5
+ */
6
+ class EmailBodyCache {
7
+ constructor() {
8
+ this.cache = new Map();
9
+ }
10
+
11
+ get(key) {
12
+ return this.cache.get(key) ?? null;
13
+ }
14
+
15
+ set(key, value) {
16
+ this.cache.set(key, value);
17
+ }
18
+ }
19
+
20
+ module.exports = EmailBodyCache;
@@ -0,0 +1,94 @@
1
+ const errors = require('@tryghost/errors');
2
+ const tpl = require('@tryghost/tpl');
3
+
4
+ const messages = {
5
+ postNotFound: 'Post not found.',
6
+ noEmailsProvided: 'No emails provided.',
7
+ emailNotFound: 'Email not found.',
8
+ tooManyEmailsProvided: 'Too many emails provided. Maximum of 1 test email can be sent at once.'
9
+ };
10
+
11
+ class EmailController {
12
+ service;
13
+ models;
14
+
15
+ /**
16
+ *
17
+ * @param {EmailService} service
18
+ * @param {{models: {Post: any, Newsletter: any, Email: any}}} dependencies
19
+ */
20
+ constructor(service, {models}) {
21
+ this.service = service;
22
+ this.models = models;
23
+ }
24
+
25
+ async _getFrameData(frame) {
26
+ // Bit absurd situation in email-previews endpoints that one endpoint is using options and other one is using data.
27
+ // So we need to handle both cases.
28
+ let post;
29
+ if (frame.options.id) {
30
+ post = await this.models.Post.findOne({...frame.options, status: 'all'}, {withRelated: ['posts_meta', 'authors']});
31
+ } else {
32
+ post = await this.models.Post.findOne({...frame.data, status: 'all'}, {...frame.options, withRelated: ['posts_meta', 'authors']});
33
+ }
34
+
35
+ if (!post) {
36
+ throw new errors.NotFoundError({
37
+ message: tpl(messages.postNotFound)
38
+ });
39
+ }
40
+
41
+ let newsletter;
42
+ const slug = frame?.options?.newsletter ?? frame?.data?.newsletter ?? null;
43
+ if (slug) {
44
+ newsletter = await this.models.Newsletter.findOne({slug}, {require: true});
45
+ } else {
46
+ newsletter = (await post.getLazyRelation('newsletter')) ?? (await this.models.Newsletter.getDefaultNewsletter());
47
+ }
48
+ return {
49
+ post,
50
+ newsletter,
51
+ segment: frame.options.memberSegment ?? frame.data.memberSegment ?? null
52
+ };
53
+ }
54
+
55
+ async previewEmail(frame) {
56
+ const {post, newsletter, segment} = await this._getFrameData(frame);
57
+ return await this.service.previewEmail(post, newsletter, segment);
58
+ }
59
+
60
+ async sendTestEmail(frame) {
61
+ const {post, newsletter, segment} = await this._getFrameData(frame);
62
+
63
+ const emails = frame.data.emails ?? [];
64
+
65
+ if (emails.length === 0) {
66
+ throw new errors.ValidationError({
67
+ message: tpl(messages.noEmailsProvided)
68
+ });
69
+ }
70
+
71
+ // test emails are limited to 1
72
+ if (emails.length > 1) {
73
+ throw new errors.ValidationError({
74
+ message: tpl(messages.tooManyEmailsProvided)
75
+ });
76
+ }
77
+
78
+ await this.service.sendTestEmail(post, newsletter, segment, emails);
79
+ }
80
+
81
+ async retryFailedEmail(frame) {
82
+ const email = await this.models.Email.findOne(frame.data, {require: false});
83
+
84
+ if (!email) {
85
+ throw new errors.NotFoundError({
86
+ message: tpl(messages.emailNotFound)
87
+ });
88
+ }
89
+
90
+ return await this.service.retryEmail(email);
91
+ }
92
+ }
93
+
94
+ module.exports = EmailController;
@@ -0,0 +1,267 @@
1
+ const logging = require('@tryghost/logging');
2
+
3
+ const EmailDeliveredEvent = require('./events/EmailDeliveredEvent');
4
+ const EmailOpenedEvent = require('./events/EmailOpenedEvent');
5
+ const EmailBouncedEvent = require('./events/EmailBouncedEvent');
6
+ const EmailTemporaryBouncedEvent = require('./events/EmailTemporaryBouncedEvent');
7
+ const EmailUnsubscribedEvent = require('./events/EmailUnsubscribedEvent');
8
+ const SpamComplaintEvent = require('./events/SpamComplaintEvent');
9
+
10
+ async function waitForEvent() {
11
+ return new Promise((resolve) => {
12
+ setTimeout(resolve, 70);
13
+ });
14
+ }
15
+
16
+ /**
17
+ * @typedef EmailIdentification
18
+ * @property {string} email
19
+ * @property {string} providerId
20
+ * @property {string} [emailId] Optional email id
21
+ */
22
+
23
+ /**
24
+ * @typedef EmailRecipientInformation
25
+ * @property {string} emailRecipientId
26
+ * @property {string} memberId
27
+ * @property {string} emailId
28
+ */
29
+
30
+ /**
31
+ * @typedef EmailEventStorage
32
+ * @property {(event: EmailDeliveredEvent) => Promise<void>} handleDelivered
33
+ * @property {(event: EmailOpenedEvent) => Promise<void>} handleOpened
34
+ * @property {(event: EmailBouncedEvent) => Promise<void>} handlePermanentFailed
35
+ * @property {(event: EmailTemporaryBouncedEvent) => Promise<void>} handleTemporaryFailed
36
+ * @property {(event: EmailUnsubscribedEvent) => Promise<void>} handleUnsubscribed
37
+ * @property {(event: SpamComplaintEvent) => Promise<void>} handleComplained
38
+ */
39
+
40
+ /**
41
+ * WARNING: this class is used in a separate thread (an offloaded job). Be careful when working with settings and models.
42
+ */
43
+ class EmailEventProcessor {
44
+ #domainEvents;
45
+ #db;
46
+ #eventStorage;
47
+ #prometheusClient;
48
+ constructor({domainEvents, db, eventStorage, prometheusClient}) {
49
+ this.#domainEvents = domainEvents;
50
+ this.#db = db;
51
+ this.#eventStorage = eventStorage;
52
+ this.#prometheusClient = prometheusClient;
53
+ // Avoid having to query email_batch by provider_id for every event
54
+ this.providerIdEmailIdMap = {};
55
+
56
+ if (this.#prometheusClient) {
57
+ this.#prometheusClient.registerCounter({
58
+ name: 'email_analytics_events_processed',
59
+ help: 'Number of email analytics events processed',
60
+ labelNames: ['event']
61
+ });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * @param {EmailIdentification} emailIdentification
67
+ * @param {Date} timestamp
68
+ */
69
+ async handleDelivered(emailIdentification, timestamp) {
70
+ const recipient = await this.getRecipient(emailIdentification);
71
+ if (recipient) {
72
+ const event = EmailDeliveredEvent.create({
73
+ email: emailIdentification.email,
74
+ emailRecipientId: recipient.emailRecipientId,
75
+ memberId: recipient.memberId,
76
+ emailId: recipient.emailId,
77
+ timestamp
78
+ });
79
+ await this.#eventStorage.handleDelivered(event);
80
+
81
+ this.#domainEvents.dispatch(event);
82
+ this.recordEventProcessed('delivered');
83
+ }
84
+ return recipient;
85
+ }
86
+
87
+ /**
88
+ * @param {EmailIdentification} emailIdentification
89
+ * @param {Date} timestamp
90
+ */
91
+ async handleOpened(emailIdentification, timestamp) {
92
+ const recipient = await this.getRecipient(emailIdentification);
93
+ if (recipient) {
94
+ const event = EmailOpenedEvent.create({
95
+ email: emailIdentification.email,
96
+ emailRecipientId: recipient.emailRecipientId,
97
+ memberId: recipient.memberId,
98
+ emailId: recipient.emailId,
99
+ timestamp
100
+ });
101
+ this.#domainEvents.dispatch(event);
102
+ await this.#eventStorage.handleOpened(event);
103
+ this.recordEventProcessed('opened');
104
+ }
105
+ return recipient;
106
+ }
107
+
108
+ /**
109
+ * @param {EmailIdentification} emailIdentification
110
+ * @param {{id: string, timestamp: Date, error: {code: number; message: string; enhandedCode: string|number} | null}} event
111
+ */
112
+ async handleTemporaryFailed(emailIdentification, {timestamp, error, id}) {
113
+ const recipient = await this.getRecipient(emailIdentification);
114
+ if (recipient) {
115
+ const event = EmailTemporaryBouncedEvent.create({
116
+ id,
117
+ error,
118
+ email: emailIdentification.email,
119
+ memberId: recipient.memberId,
120
+ emailId: recipient.emailId,
121
+ emailRecipientId: recipient.emailRecipientId,
122
+ timestamp
123
+ });
124
+ await this.#eventStorage.handleTemporaryFailed(event);
125
+
126
+ this.#domainEvents.dispatch(event);
127
+ }
128
+ return recipient;
129
+ }
130
+
131
+ /**
132
+ * @param {EmailIdentification} emailIdentification
133
+ * @param {{id: string, timestamp: Date, error: {code: number; message: string; enhandedCode: string|number} | null}} event
134
+ */
135
+ async handlePermanentFailed(emailIdentification, {timestamp, error, id}) {
136
+ const recipient = await this.getRecipient(emailIdentification);
137
+ if (recipient) {
138
+ const event = EmailBouncedEvent.create({
139
+ id,
140
+ error,
141
+ email: emailIdentification.email,
142
+ memberId: recipient.memberId,
143
+ emailId: recipient.emailId,
144
+ emailRecipientId: recipient.emailRecipientId,
145
+ timestamp
146
+ });
147
+ await this.#eventStorage.handlePermanentFailed(event);
148
+
149
+ this.#domainEvents.dispatch(event);
150
+ await waitForEvent(); // Avoids knex connection pool to run dry
151
+ }
152
+ return recipient;
153
+ }
154
+
155
+ /**
156
+ * @param {EmailIdentification} emailIdentification
157
+ * @param {Date} timestamp
158
+ */
159
+ async handleUnsubscribed(emailIdentification, timestamp) {
160
+ const recipient = await this.getRecipient(emailIdentification);
161
+ if (recipient) {
162
+ const event = EmailUnsubscribedEvent.create({
163
+ email: emailIdentification.email,
164
+ memberId: recipient.memberId,
165
+ emailId: recipient.emailId,
166
+ timestamp
167
+ });
168
+ await this.#eventStorage.handleUnsubscribed(event);
169
+
170
+ this.#domainEvents.dispatch(event);
171
+ }
172
+ return recipient;
173
+ }
174
+
175
+ /**
176
+ * @param {EmailIdentification} emailIdentification
177
+ * @param {Date} timestamp
178
+ */
179
+ async handleComplained(emailIdentification, timestamp) {
180
+ const recipient = await this.getRecipient(emailIdentification);
181
+ if (recipient) {
182
+ const event = SpamComplaintEvent.create({
183
+ email: emailIdentification.email,
184
+ memberId: recipient.memberId,
185
+ emailId: recipient.emailId,
186
+ timestamp
187
+ });
188
+ await this.#eventStorage.handleComplained(event);
189
+
190
+ this.#domainEvents.dispatch(event);
191
+ await waitForEvent(); // Avoids knex connection pool to run dry
192
+ }
193
+ return recipient;
194
+ }
195
+
196
+ /**
197
+ * @private
198
+ * @param {EmailIdentification} emailIdentification
199
+ * @returns {Promise<EmailRecipientInformation|undefined>}
200
+ */
201
+ async getRecipient(emailIdentification) {
202
+ if (!emailIdentification.emailId && !emailIdentification.providerId) {
203
+ // Protection if both are null or undefined
204
+ return;
205
+ }
206
+
207
+ // With the provider_id and email address we can look for the EmailRecipient
208
+ const emailId = emailIdentification.emailId ?? await this.getEmailId(emailIdentification.providerId);
209
+ if (!emailId) {
210
+ // Invalid
211
+ return;
212
+ }
213
+
214
+ const {id: emailRecipientId, member_id: memberId} = await this.#db.knex('email_recipients')
215
+ .select('id', 'member_id')
216
+ .where('member_email', emailIdentification.email)
217
+ .where('email_id', emailId)
218
+ .first() || {};
219
+
220
+ if (emailRecipientId && memberId) {
221
+ return {
222
+ emailRecipientId,
223
+ memberId,
224
+ emailId
225
+ };
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Record event processed
231
+ * @param {string} event
232
+ */
233
+ recordEventProcessed(event) {
234
+ try {
235
+ if (this.#prometheusClient) {
236
+ this.#prometheusClient.getMetric('email_analytics_events_processed')?.inc({event});
237
+ }
238
+ } catch (err) {
239
+ logging.error('Error recording email analytics event processed', err);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * @private
245
+ * @param {string} providerId
246
+ * @returns {Promise<string|undefined>}
247
+ */
248
+ async getEmailId(providerId) {
249
+ if (this.providerIdEmailIdMap[providerId]) {
250
+ return this.providerIdEmailIdMap[providerId];
251
+ }
252
+
253
+ const {emailId} = await this.#db.knex('email_batches')
254
+ .select('email_id as emailId')
255
+ .where('provider_id', providerId)
256
+ .first() || {};
257
+
258
+ if (!emailId) {
259
+ return;
260
+ }
261
+
262
+ this.providerIdEmailIdMap[providerId] = emailId;
263
+ return emailId;
264
+ }
265
+ }
266
+
267
+ module.exports = EmailEventProcessor;
@@ -0,0 +1,187 @@
1
+ const moment = require('moment-timezone');
2
+ const logging = require('@tryghost/logging');
3
+
4
+ class EmailEventStorage {
5
+ #db;
6
+ #membersRepository;
7
+ #models;
8
+ #emailSuppressionList;
9
+ #prometheusClient;
10
+
11
+ constructor({db, models, membersRepository, emailSuppressionList, prometheusClient}) {
12
+ this.#db = db;
13
+ this.#models = models;
14
+ this.#membersRepository = membersRepository;
15
+ this.#emailSuppressionList = emailSuppressionList;
16
+ this.#prometheusClient = prometheusClient;
17
+
18
+ if (this.#prometheusClient) {
19
+ this.#prometheusClient.registerCounter({
20
+ name: 'email_analytics_events_stored',
21
+ help: 'Number of email analytics events stored',
22
+ labelNames: ['event']
23
+ });
24
+ }
25
+ }
26
+
27
+ async handleDelivered(event) {
28
+ // To properly handle events that are received out of order (this happens because of polling)
29
+ // only set if delivered_at is null
30
+ const rowCount = await this.#db.knex('email_recipients')
31
+ .where('id', '=', event.emailRecipientId)
32
+ .whereNull('delivered_at')
33
+ .update({
34
+ delivered_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
35
+ });
36
+ this.recordEventStored('delivered', rowCount);
37
+ }
38
+
39
+ async handleOpened(event) {
40
+ // To properly handle events that are received out of order (this happens because of polling)
41
+ // only set if opened_at is null
42
+ const rowCount = await this.#db.knex('email_recipients')
43
+ .where('id', '=', event.emailRecipientId)
44
+ .whereNull('opened_at')
45
+ .update({
46
+ opened_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
47
+ });
48
+ this.recordEventStored('opened', rowCount);
49
+ }
50
+
51
+ async handlePermanentFailed(event) {
52
+ // To properly handle events that are received out of order (this happens because of polling)
53
+ // only set if failed_at is null
54
+ await this.#db.knex('email_recipients')
55
+ .where('id', '=', event.emailRecipientId)
56
+ .whereNull('failed_at')
57
+ .update({
58
+ failed_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
59
+ });
60
+ await this.saveFailure('permanent', event);
61
+ }
62
+
63
+ async handleTemporaryFailed(event) {
64
+ await this.saveFailure('temporary', event);
65
+ }
66
+
67
+ /**
68
+ * @private
69
+ * @param {'temporary'|'permanent'} severity
70
+ * @param {import('./events/EmailTemporaryBouncedEvent')|import('./events/EmailBouncedEvent')} event
71
+ * @param {{transacting?: any}} options
72
+ * @returns
73
+ */
74
+ async saveFailure(severity, event, options = {}) {
75
+ if (!event.error) {
76
+ logging.warn(`Missing error information provided for ${severity} failure event with id ${event.id}`);
77
+ return;
78
+ }
79
+
80
+ if (!options || !options.transacting) {
81
+ return await this.#models.EmailRecipientFailure.transaction(async (transacting) => {
82
+ await this.saveFailure(severity, event, {transacting});
83
+ });
84
+ }
85
+
86
+ // Create a forUpdate transaction
87
+ const existing = await this.#models.EmailRecipientFailure.findOne({
88
+ email_recipient_id: event.emailRecipientId
89
+ }, {...options, require: false, forUpdate: true});
90
+
91
+ if (!existing) {
92
+ // Create a new failure
93
+ await this.#models.EmailRecipientFailure.add({
94
+ email_id: event.emailId,
95
+ member_id: event.memberId,
96
+ email_recipient_id: event.emailRecipientId,
97
+ severity,
98
+ message: event.error.message || `Error ${event.error.enhancedCode ?? event.error.code}`,
99
+ code: event.error.code,
100
+ enhanced_code: event.error.enhancedCode,
101
+ failed_at: event.timestamp,
102
+ event_id: event.id
103
+ }, {...options, autoRefresh: false});
104
+ } else {
105
+ if (existing.get('severity') === 'permanent') {
106
+ // Already marked as failed, no need to change anything here
107
+ return;
108
+ }
109
+
110
+ if (existing.get('failed_at') > event.timestamp) {
111
+ /// We can get events out of order, so only save the last one
112
+ return;
113
+ }
114
+
115
+ // Update the existing failure
116
+ await existing.save({
117
+ severity,
118
+ message: event.error.message || `Error ${event.error.enhancedCode ?? event.error.code}`,
119
+ code: event.error.code,
120
+ enhanced_code: event.error.enhancedCode ?? null,
121
+ failed_at: event.timestamp,
122
+ event_id: event.id
123
+ }, {...options, patch: true, autoRefresh: false});
124
+ }
125
+ }
126
+
127
+ async handleUnsubscribed(event) {
128
+ try {
129
+ // Unsubscribe member from the specific newsletter
130
+ const newsletters = await this.findNewslettersToKeep(event);
131
+ await this.#membersRepository.update({newsletters}, {id: event.memberId});
132
+
133
+ // Remove member from Mailgun's suppression list
134
+ await this.#emailSuppressionList.removeUnsubscribe(event.email);
135
+ } catch (err) {
136
+ logging.error(err);
137
+ }
138
+ }
139
+
140
+ async handleComplained(event) {
141
+ try {
142
+ await this.#models.EmailSpamComplaintEvent.add({
143
+ member_id: event.memberId,
144
+ email_id: event.emailId,
145
+ email_address: event.email
146
+ });
147
+ } catch (err) {
148
+ if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
149
+ logging.error(err);
150
+ }
151
+ }
152
+ }
153
+
154
+ async findNewslettersToKeep(event) {
155
+ try {
156
+ const member = await this.#membersRepository.get({email: event.email}, {
157
+ withRelated: ['newsletters']
158
+ });
159
+ const existingNewsletters = member.related('newsletters');
160
+
161
+ const email = await this.#models.Email.findOne({id: event.emailId});
162
+ const newsletterToRemove = email.get('newsletter_id');
163
+
164
+ return existingNewsletters.models.filter(newsletter => newsletter.id !== newsletterToRemove).map((n) => {
165
+ return {id: n.id};
166
+ });
167
+ } catch (err) {
168
+ logging.error(err);
169
+ return [];
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Record event stored
175
+ * @param {string} event
176
+ * @param {number} count
177
+ */
178
+ recordEventStored(event, count = 1) {
179
+ try {
180
+ this.#prometheusClient?.getMetric('email_analytics_events_stored')?.inc({event}, count);
181
+ } catch (err) {
182
+ logging.error('Error recording email analytics event stored', err);
183
+ }
184
+ }
185
+ }
186
+
187
+ module.exports = EmailEventStorage;