ghost 5.119.3 → 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 (129) 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.2697b46a5652693fc674.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/repositories/MemberRepository.js +92 -92
  99. package/core/server/services/members-events/LastSeenAtUpdater.js +1 -1
  100. package/core/server/services/settings-helpers/SettingsHelpers.js +1 -1
  101. package/core/server/services/staff/StaffServiceEmails.js +1 -1
  102. package/core/server/services/stats/PostsStatsService.js +28 -7
  103. package/core/server/web/api/app.js +0 -1
  104. package/core/server/web/api/endpoints/admin/app.js +0 -2
  105. package/core/server/web/api/endpoints/content/app.js +0 -2
  106. package/core/server/web/api/middleware/upload.js +2 -2
  107. package/core/shared/custom-theme-settings-cache/CustomThemeSettingsService.js +2 -1
  108. package/package.json +39 -97
  109. package/tsconfig.tsbuildinfo +1 -1
  110. package/yarn.lock +385 -517
  111. package/components/tryghost-api-framework-5.119.3.tgz +0 -0
  112. package/components/tryghost-custom-fonts-5.119.3.tgz +0 -0
  113. package/components/tryghost-domain-events-5.119.3.tgz +0 -0
  114. package/components/tryghost-email-addresses-5.119.3.tgz +0 -0
  115. package/components/tryghost-email-service-5.119.3.tgz +0 -0
  116. package/components/tryghost-html-to-plaintext-5.119.3.tgz +0 -0
  117. package/components/tryghost-i18n-5.119.3.tgz +0 -0
  118. package/components/tryghost-job-manager-5.119.3.tgz +0 -0
  119. package/components/tryghost-members-csv-5.119.3.tgz +0 -0
  120. package/components/tryghost-mw-error-handler-5.119.3.tgz +0 -0
  121. package/components/tryghost-mw-vhost-5.119.3.tgz +0 -0
  122. package/components/tryghost-prometheus-metrics-5.119.3.tgz +0 -0
  123. package/components/tryghost-security-5.119.3.tgz +0 -0
  124. package/core/built/admin/assets/chunk.524.c86e2e1b3e94d7cb1e4c.js +0 -35
  125. package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +0 -99
  126. package/core/server/services/api-version-compatibility/VersionNotificationsDataService.js +0 -80
  127. package/core/server/services/api-version-compatibility/extract-api-key.js +0 -57
  128. package/core/server/services/api-version-compatibility/mw-api-version-mismatch.js +0 -31
  129. /package/core/built/admin/assets/{chunk.137.c9bf40f01afeeadb4660.js.LICENSE.txt → chunk.383.25fca2f09b4896656125.js.LICENSE.txt} +0 -0
@@ -0,0 +1,703 @@
1
+ const logging = require('@tryghost/logging');
2
+ const ObjectID = require('bson-objectid').default;
3
+ const errors = require('@tryghost/errors');
4
+ const tpl = require('@tryghost/tpl');
5
+ const EmailBodyCache = require('./EmailBodyCache');
6
+
7
+ const messages = {
8
+ emailErrorPartialFailure: 'An error occurred, and your newsletter was only partially sent. Please retry sending the remaining emails.',
9
+ emailError: 'An unexpected error occurred, please retry sending your newsletter.'
10
+ };
11
+
12
+ const MAX_SENDING_CONCURRENCY = 2;
13
+
14
+ /**
15
+ * @typedef {import('./SendingService')} SendingService
16
+ * @typedef {import('./EmailSegmenter')} EmailSegmenter
17
+ * @typedef {import('./EmailRenderer')} EmailRenderer
18
+ * @typedef {import('./EmailRenderer').MemberLike} MemberLike
19
+ * @typedef {object} JobsService
20
+ * @typedef {object} Email
21
+ * @typedef {object} Newsletter
22
+ * @typedef {object} Post
23
+ * @typedef {object} EmailBatch
24
+ */
25
+
26
+ class BatchSendingService {
27
+ #emailRenderer;
28
+ #sendingService;
29
+ #emailSegmenter;
30
+ #jobsService;
31
+ #models;
32
+ #db;
33
+ #sentry;
34
+ #debugStorageFilePath;
35
+
36
+ // Retry database queries happening before sending the email
37
+ #BEFORE_RETRY_CONFIG = {maxRetries: 10, maxTime: 10 * 60 * 1000, sleep: 2000};
38
+ #AFTER_RETRY_CONFIG = {maxRetries: 20, maxTime: 30 * 60 * 1000, sleep: 2000};
39
+ #MAILGUN_API_RETRY_CONFIG = {sleep: 10 * 1000, maxRetries: 6};
40
+
41
+ /**
42
+ * @param {Object} dependencies
43
+ * @param {EmailRenderer} dependencies.emailRenderer
44
+ * @param {SendingService} dependencies.sendingService
45
+ * @param {JobsService} dependencies.jobsService
46
+ * @param {EmailSegmenter} dependencies.emailSegmenter
47
+ * @param {object} dependencies.models
48
+ * @param {object} dependencies.models.EmailRecipient
49
+ * @param {EmailBatch} dependencies.models.EmailBatch
50
+ * @param {Email} dependencies.models.Email
51
+ * @param {object} dependencies.models.Member
52
+ * @param {object} dependencies.db
53
+ * @param {object} [dependencies.sentry]
54
+ * @param {object} [dependencies.BEFORE_RETRY_CONFIG]
55
+ * @param {object} [dependencies.AFTER_RETRY_CONFIG]
56
+ * @param {object} [dependencies.MAILGUN_API_RETRY_CONFIG]
57
+ * @param {string} [dependencies.debugStorageFilePath]
58
+ */
59
+ constructor({
60
+ emailRenderer,
61
+ sendingService,
62
+ jobsService,
63
+ emailSegmenter,
64
+ models,
65
+ db,
66
+ sentry,
67
+ BEFORE_RETRY_CONFIG,
68
+ AFTER_RETRY_CONFIG,
69
+ MAILGUN_API_RETRY_CONFIG,
70
+ debugStorageFilePath
71
+ }) {
72
+ this.#emailRenderer = emailRenderer;
73
+ this.#sendingService = sendingService;
74
+ this.#jobsService = jobsService;
75
+ this.#emailSegmenter = emailSegmenter;
76
+ this.#models = models;
77
+ this.#db = db;
78
+ this.#sentry = sentry;
79
+ this.#debugStorageFilePath = debugStorageFilePath;
80
+
81
+ if (BEFORE_RETRY_CONFIG) {
82
+ this.#BEFORE_RETRY_CONFIG = BEFORE_RETRY_CONFIG;
83
+ } else {
84
+ if (process.env.NODE_ENV.startsWith('test') || process.env.NODE_ENV === 'development') {
85
+ this.#BEFORE_RETRY_CONFIG = {maxRetries: 0};
86
+ }
87
+ }
88
+ if (AFTER_RETRY_CONFIG) {
89
+ this.#AFTER_RETRY_CONFIG = AFTER_RETRY_CONFIG;
90
+ } else {
91
+ if (process.env.NODE_ENV.startsWith('test') || process.env.NODE_ENV === 'development') {
92
+ this.#AFTER_RETRY_CONFIG = {maxRetries: 0};
93
+ }
94
+ }
95
+
96
+ if (MAILGUN_API_RETRY_CONFIG) {
97
+ this.#MAILGUN_API_RETRY_CONFIG = MAILGUN_API_RETRY_CONFIG;
98
+ } else {
99
+ if (process.env.NODE_ENV.startsWith('test') || process.env.NODE_ENV === 'development') {
100
+ this.#MAILGUN_API_RETRY_CONFIG = {maxRetries: 0};
101
+ }
102
+ }
103
+ }
104
+
105
+ #getBeforeRetryConfig(email) {
106
+ if (email._retryCutOffTime) {
107
+ return {...this.#BEFORE_RETRY_CONFIG, stopAfterDate: email._retryCutOffTime};
108
+ }
109
+ return this.#BEFORE_RETRY_CONFIG;
110
+ }
111
+
112
+ /**
113
+ * Schedules a background job that sends the email in the background if it is pending or failed.
114
+ * @param {Email} email
115
+ * @returns {void}
116
+ */
117
+ scheduleEmail(email) {
118
+ return this.#jobsService.addJob({
119
+ name: 'batch-sending-service-job',
120
+ job: this.emailJob.bind(this),
121
+ data: {emailId: email.id},
122
+ offloaded: false
123
+ });
124
+ }
125
+
126
+ /**
127
+ * @private
128
+ * @param {{emailId: string}} data Data passed from the job service. We only need the emailId because we need to refetch the email anyway to make sure the status is right and 'locked'.
129
+ */
130
+ async emailJob({emailId}) {
131
+ logging.info(`Starting email job for email ${emailId}`);
132
+
133
+ const startTime = Date.now();
134
+
135
+ // Check if email is 'pending' only + change status to submitting in one transaction.
136
+ // This allows us to have a lock around the email job that makes sure an email can only have one active job.
137
+ let email = await this.retryDb(
138
+ async () => {
139
+ return await this.updateStatusLock(this.#models.Email, emailId, 'submitting', ['pending', 'failed']);
140
+ },
141
+ {...this.#BEFORE_RETRY_CONFIG, description: `updateStatusLock email ${emailId} -> submitting`}
142
+ );
143
+ if (!email) {
144
+ logging.error(`Tried sending email that is not pending or failed ${emailId}`);
145
+ return;
146
+ }
147
+
148
+ // We'll stop all automatic DB retries after this date
149
+ const expectedBatchCount = Math.ceil(email.get('email_count') / 1000);
150
+ const minimumSecondsPerBatch = 26; // In case of database issues, we make sure we expand the retry window relative to the amount of batches
151
+ const stopAfter = Math.max(expectedBatchCount * minimumSecondsPerBatch * 1000, this.#BEFORE_RETRY_CONFIG.maxTime);
152
+ const retryCutOffTime = new Date(startTime + stopAfter);
153
+
154
+ // Save a strict cutoff time for retries
155
+ email._retryCutOffTime = retryCutOffTime;
156
+
157
+ try {
158
+ await this.sendEmail(email);
159
+ await this.retryDb(async () => {
160
+ await email.save({
161
+ status: 'submitted',
162
+ submitted_at: new Date(),
163
+ error: null
164
+ }, {patch: true, autoRefresh: false});
165
+ }, {...this.#AFTER_RETRY_CONFIG, description: `email ${emailId} -> submitted`});
166
+ } catch (e) {
167
+ const ghostError = new errors.EmailError({
168
+ err: e,
169
+ code: 'BULK_EMAIL_SEND_FAILED',
170
+ message: `Error sending email ${email.id}`
171
+ });
172
+
173
+ logging.error(ghostError);
174
+ if (this.#sentry) {
175
+ // Log the original error to Sentry
176
+ this.#sentry.captureException(e);
177
+ }
178
+
179
+ // Store error and status in email model
180
+ await this.retryDb(async () => {
181
+ await email.save({
182
+ status: 'failed',
183
+ error: e.message || 'Something went wrong while sending the email'
184
+ }, {patch: true, autoRefresh: false});
185
+ }, {...this.#AFTER_RETRY_CONFIG, description: `email ${emailId} -> failed`});
186
+ }
187
+ }
188
+
189
+ /**
190
+ * @private
191
+ * @param {Email} email
192
+ * @throws {errors.EmailError} If one of the batches fails
193
+ */
194
+ async sendEmail(email) {
195
+ logging.info(`Sending email ${email.id}`);
196
+
197
+ // Load required relations
198
+ const newsletter = await this.retryDb(async () => {
199
+ return await email.getLazyRelation('newsletter', {require: true});
200
+ }, {...this.#getBeforeRetryConfig(email), description: `getLazyRelation newsletter for email ${email.id}`});
201
+
202
+ const post = await this.retryDb(async () => {
203
+ return await email.getLazyRelation('post', {require: true, withRelated: ['posts_meta', 'authors']});
204
+ }, {...this.#getBeforeRetryConfig(email), description: `getLazyRelation post for email ${email.id}`});
205
+
206
+ let batches = await this.retryDb(async () => {
207
+ return await this.getBatches(email);
208
+ }, {...this.#getBeforeRetryConfig(email), description: `getBatches for email ${email.id}`});
209
+
210
+ if (batches.length === 0) {
211
+ batches = await this.createBatches({email, newsletter, post});
212
+ }
213
+ await this.sendBatches({email, batches, post, newsletter});
214
+ }
215
+
216
+ /**
217
+ * @private
218
+ * @param {Email} email
219
+ * @returns {Promise<EmailBatch[]>}
220
+ */
221
+ async getBatches(email) {
222
+ logging.info(`Getting batches for email ${email.id}`);
223
+
224
+ // findAll returns a bookshelf collection, we want to return a plain array to align with the createBatches method
225
+ const batches = await this.#models.EmailBatch.findAll({filter: 'email_id:\'' + email.id + '\''});
226
+ return batches.models;
227
+ }
228
+
229
+ /**
230
+ * @private
231
+ * @param {{email: Email, newsletter: Newsletter, post: Post}} data
232
+ * @returns {Promise<EmailBatch[]>}
233
+ */
234
+ async createBatches({email, post, newsletter}) {
235
+ logging.info(`Creating batches for email ${email.id}`);
236
+
237
+ const segments = await this.#emailRenderer.getSegments(post);
238
+ const batches = [];
239
+ const BATCH_SIZE = this.#sendingService.getMaximumRecipients();
240
+ let totalCount = 0;
241
+
242
+ for (const segment of segments) {
243
+ logging.info(`Creating batches for email ${email.id} segment ${segment}`);
244
+
245
+ const segmentFilter = this.#emailSegmenter.getMemberFilterForSegment(newsletter, email.get('recipient_filter'), segment);
246
+
247
+ // Avoiding Bookshelf for performance reasons
248
+ let members;
249
+
250
+ // Start with the id of the email, which is an objectId. We'll only fetch members that are created before the email. This is a special property of ObjectIds.
251
+ // Note: we use ID and not created_at, because imported members could set a created_at in the future or past and avoid limit checking.
252
+ let lastId = email.id;
253
+
254
+ while (!members || lastId) {
255
+ logging.info(`Fetching members batch for email ${email.id} segment ${segment}, lastId: ${lastId}`);
256
+
257
+ const filter = segmentFilter + `+id:<'${lastId}'`;
258
+ logging.info(`Fetching members batch for email ${email.id} segment ${segment}, lastId: ${lastId} ${filter}`);
259
+
260
+ members = await this.#models.Member.getFilteredCollectionQuery({filter})
261
+ .orderByRaw('id DESC')
262
+ .select('members.id', 'members.uuid', 'members.email', 'members.name').limit(BATCH_SIZE + 1);
263
+
264
+ if (members.length > 0) {
265
+ totalCount += Math.min(members.length, BATCH_SIZE);
266
+ const batch = await this.retryDb(
267
+ async () => {
268
+ return await this.createBatch(email, segment, members.slice(0, BATCH_SIZE));
269
+ },
270
+ {...this.#getBeforeRetryConfig(email), description: `createBatch email ${email.id} segment ${segment}`}
271
+ );
272
+ batches.push(batch);
273
+ }
274
+
275
+ if (members.length > BATCH_SIZE) {
276
+ lastId = members[members.length - 2].id;
277
+ } else {
278
+ break;
279
+ }
280
+ }
281
+ }
282
+
283
+ logging.info(`Created ${batches.length} batches for email ${email.id} with ${totalCount} recipients`);
284
+
285
+ if (email.get('email_count') !== totalCount) {
286
+ logging.error(`Email ${email.id} has wrong stored email_count ${email.get('email_count')}, did expect ${totalCount}. Updating the model.`);
287
+
288
+ // If the error rate is greater than 1%, we log it to Sentry so we can investigate
289
+ // Some differences are expected, e.g. if a new member signs up while we are sending the email
290
+ const errorRate = Math.abs((totalCount - email.get('email_count')) / email.get('email_count'));
291
+ if (this.#sentry && errorRate >= 0.01) {
292
+ // we don't have a real exception, so just log a message to Sentry
293
+ this.#sentry.captureMessage(`Email ${email.id} has wrong stored email_count ${email.get('email_count')}, did expect ${totalCount}.`);
294
+ }
295
+
296
+ // We update the email model because this might happen in rare cases where the initial member count changed (e.g. deleted members)
297
+ // between creating the email and sending it
298
+ await email.save({
299
+ email_count: totalCount
300
+ }, {patch: true, require: false, autoRefresh: false});
301
+ }
302
+ return batches;
303
+ }
304
+
305
+ /**
306
+ * @private
307
+ * @param {Email} email
308
+ * @param {import('./EmailRenderer').Segment} segment
309
+ * @param {object[]} members
310
+ * @returns {Promise<EmailBatch>}
311
+ */
312
+ async createBatch(email, segment, members, options) {
313
+ if (!options || !options.transacting) {
314
+ return this.#models.EmailBatch.transaction(async (transacting) => {
315
+ return this.createBatch(email, segment, members, {transacting});
316
+ });
317
+ }
318
+
319
+ logging.info(`Creating batch for email ${email.id} segment ${segment} with ${members.length} members`);
320
+
321
+ const batch = await this.#models.EmailBatch.add({
322
+ email_id: email.id,
323
+ member_segment: segment,
324
+ status: 'pending'
325
+ }, options);
326
+
327
+ const recipientData = [];
328
+
329
+ members.forEach((memberRow) => {
330
+ if (!memberRow.id || !memberRow.uuid || !memberRow.email) {
331
+ logging.warn(`Member row not included as email recipient due to missing data - id: ${memberRow.id}, uuid: ${memberRow.uuid}, email: ${memberRow.email}`);
332
+ return;
333
+ }
334
+
335
+ recipientData.push({
336
+ id: ObjectID().toHexString(),
337
+ email_id: email.id,
338
+ member_id: memberRow.id,
339
+ batch_id: batch.id,
340
+ member_uuid: memberRow.uuid,
341
+ member_email: memberRow.email,
342
+ member_name: memberRow.name
343
+ });
344
+ });
345
+
346
+ const insertQuery = this.#db.knex('email_recipients').insert(recipientData);
347
+
348
+ if (options.transacting) {
349
+ insertQuery.transacting(options.transacting);
350
+ }
351
+
352
+ logging.info(`Inserting ${recipientData.length} recipients for email ${email.id} batch ${batch.id}`);
353
+ await insertQuery;
354
+ return batch;
355
+ }
356
+
357
+ async sendBatches({email, batches, post, newsletter}) {
358
+ logging.info(`Sending ${batches.length} batches for email ${email.id}`);
359
+ const deadline = this.getDeliveryDeadline(email);
360
+
361
+ if (deadline) {
362
+ logging.info(`Delivery deadline for email ${email.id} is ${deadline}`);
363
+ }
364
+ // Reuse same HTML body if we send an email to the same segment
365
+ const emailBodyCache = new EmailBodyCache();
366
+
367
+ // Calculate deliverytimes for the batches
368
+ const deliveryTimes = this.calculateDeliveryTimes(email, batches.length);
369
+
370
+ // Loop batches and send them via the EmailProvider
371
+ let succeededCount = 0;
372
+ const queue = batches.slice();
373
+
374
+ // Bind this
375
+ let runNext;
376
+ runNext = async () => {
377
+ const batch = queue.shift();
378
+ if (batch) {
379
+ const batchData = {email, batch, post, newsletter, emailBodyCache, deliveryTime: undefined};
380
+ // Only set a delivery time if we have a deadline and it hasn't past yet
381
+ if (deadline && deadline.getTime() > Date.now()) {
382
+ const deliveryTime = deliveryTimes.shift();
383
+ if (deliveryTime && deliveryTime >= Date.now()) {
384
+ batchData.deliveryTime = deliveryTime;
385
+ }
386
+ }
387
+ if (await this.sendBatch(batchData)) {
388
+ succeededCount += 1;
389
+ }
390
+ await runNext();
391
+ }
392
+ };
393
+
394
+ // Run maximum MAX_SENDING_CONCURRENCY at the same time
395
+ await Promise.all(new Array(MAX_SENDING_CONCURRENCY).fill(0).map(() => runNext()));
396
+
397
+ if (succeededCount < batches.length) {
398
+ if (succeededCount > 0) {
399
+ throw new errors.EmailError({
400
+ message: tpl(messages.emailErrorPartialFailure)
401
+ });
402
+ }
403
+ throw new errors.EmailError({
404
+ message: tpl(messages.emailError)
405
+ });
406
+ }
407
+ }
408
+
409
+ /**
410
+ *
411
+ * @param {{email: Email, batch: EmailBatch, post: Post, newsletter: Newsletter, emailBodyCache: EmailBodyCache, deliveryTime:(Date|undefined) }} data
412
+ * @returns {Promise<boolean>} True when succeeded, false when failed with an error
413
+ */
414
+ async sendBatch({email, batch: originalBatch, post, newsletter, emailBodyCache, deliveryTime}) {
415
+ logging.info(`Sending batch ${originalBatch.id} for email ${email.id}`);
416
+
417
+ // Check the status of the email batch in a 'for update' transaction
418
+
419
+ const batch = await this.retryDb(
420
+ async () => {
421
+ return await this.updateStatusLock(this.#models.EmailBatch, originalBatch.id, 'submitting', ['pending', 'failed']);
422
+ },
423
+ {...this.#getBeforeRetryConfig(email), description: `updateStatusLock batch ${originalBatch.id} -> submitting`}
424
+ );
425
+ if (!batch) {
426
+ logging.error(`Tried sending email batch that is not pending or failed ${originalBatch.id}`);
427
+ return true;
428
+ }
429
+
430
+ let succeeded = false;
431
+
432
+ try {
433
+ let members = await this.retryDb(
434
+ async () => {
435
+ const m = await this.getBatchMembers(batch.id);
436
+
437
+ // If we receive 0 rows, there is a possibility that we switched to a secondary database and have replication lag
438
+ // So we throw an error and we retry
439
+ if (m.length === 0) {
440
+ throw new errors.EmailError({
441
+ message: `No members found for batch ${batch.id}, possible replication lag`
442
+ });
443
+ }
444
+
445
+ return m;
446
+ },
447
+ {...this.#getBeforeRetryConfig(email), description: `getBatchMembers batch ${originalBatch.id}`}
448
+ );
449
+
450
+ const response = await this.retryDb(async () => {
451
+ return await this.#sendingService.send({
452
+ emailId: email.id,
453
+ post,
454
+ newsletter,
455
+ segment: batch.get('member_segment'),
456
+ members
457
+ }, {
458
+ openTrackingEnabled: !!email.get('track_opens'),
459
+ clickTrackingEnabled: !!email.get('track_clicks'),
460
+ deliveryTime,
461
+ emailBodyCache
462
+ });
463
+ }, {...this.#MAILGUN_API_RETRY_CONFIG, description: `Sending email batch ${originalBatch.id} ${deliveryTime ? `with delivery time ${deliveryTime}` : ''}`});
464
+ succeeded = true;
465
+
466
+ await this.retryDb(
467
+ async () => {
468
+ await batch.save({
469
+ status: 'submitted',
470
+ provider_id: response.id,
471
+ // reset error fields when sending succeeds
472
+ error_status_code: null,
473
+ error_message: null,
474
+ error_data: null
475
+ }, {patch: true, require: false, autoRefresh: false});
476
+ },
477
+ {...this.#AFTER_RETRY_CONFIG, description: `save batch ${originalBatch.id} -> submitted`}
478
+ );
479
+ } catch (err) {
480
+ if (err.code && err.code === 'BULK_EMAIL_SEND_FAILED') {
481
+ logging.error(err);
482
+ if (this.#sentry) {
483
+ // Log the original error to Sentry
484
+ this.#sentry.captureException(err);
485
+ }
486
+ } else {
487
+ const ghostError = new errors.EmailError({
488
+ err,
489
+ code: 'BULK_EMAIL_SEND_FAILED',
490
+ message: `Error sending email batch ${batch.id}`,
491
+ context: err.message
492
+ });
493
+
494
+ logging.error(ghostError);
495
+ if (this.#sentry) {
496
+ // Log the original error to Sentry
497
+ this.#sentry.captureException(err);
498
+ }
499
+ }
500
+
501
+ if (!succeeded) {
502
+ // We check succeeded because a Rare edge case where the batch was send, but we failed to set status to submitted, then we don't want to set it to failed
503
+ await this.retryDb(
504
+ async () => {
505
+ await batch.save({
506
+ status: 'failed',
507
+ error_status_code: err.statusCode ?? null,
508
+ error_message: err.message,
509
+ error_data: err.errorDetails ?? null
510
+ }, {patch: true, require: false, autoRefresh: false});
511
+ },
512
+ {...this.#AFTER_RETRY_CONFIG, description: `save batch ${originalBatch.id} -> failed`}
513
+ );
514
+ }
515
+ }
516
+
517
+ // Mark as processed, even when failed
518
+ await this.retryDb(
519
+ async () => {
520
+ await this.#models.EmailRecipient
521
+ .where({batch_id: batch.id})
522
+ .save({processed_at: new Date()}, {patch: true, require: false, autoRefresh: false});
523
+ },
524
+ {...this.#AFTER_RETRY_CONFIG, description: `save EmailRecipients ${originalBatch.id} processed_at`}
525
+ );
526
+
527
+ return succeeded;
528
+ }
529
+
530
+ /**
531
+ * We don't want to pass EmailRecipient models to the sendingService.
532
+ * So we transform them into the MemberLike interface.
533
+ * That keeps the sending service nicely separated so it isn't dependent on the batch sending data structure.
534
+ * @returns {Promise<MemberLike[]>}
535
+ */
536
+ async getBatchMembers(batchId) {
537
+ let models = await this.#models.EmailRecipient.findAll({filter: `batch_id:'${batchId}'`, withRelated: ['member', 'member.stripeSubscriptions', 'member.products']});
538
+
539
+ const BATCH_SIZE = this.#sendingService.getMaximumRecipients();
540
+ if (models.length > BATCH_SIZE) {
541
+ throw new errors.EmailError({
542
+ message: `Email batch ${batchId} has ${models.length} members, which exceeds the maximum of ${BATCH_SIZE} members per batch.`
543
+ });
544
+ }
545
+
546
+ return models.map((model) => {
547
+ // Map subscriptions
548
+ const subscriptions = model.related('member').related('stripeSubscriptions').toJSON();
549
+ const tiers = model.related('member').related('products').toJSON();
550
+
551
+ return {
552
+ id: model.get('member_id'),
553
+ uuid: model.get('member_uuid'),
554
+ email: model.get('member_email'),
555
+ name: model.get('member_name'),
556
+ createdAt: model.related('member')?.get('created_at') ?? null,
557
+ status: model.related('member')?.get('status') ?? 'free',
558
+ subscriptions,
559
+ tiers
560
+ };
561
+ });
562
+ }
563
+
564
+ /**
565
+ * @private
566
+ * Update the status of an email or emailBatch to a given status, but first check if their current status is 'pending' or 'failed'.
567
+ * @param {object} Model Bookshelf model constructor
568
+ * @param {string} id id of the model
569
+ * @param {string} status set the status of the model to this value
570
+ * @param {string[]} allowedStatuses Check if the models current status is one of these values
571
+ * @returns {Promise<object|undefined>} The updated model. Undefined if the model didn't pass the status check.
572
+ */
573
+ async updateStatusLock(Model, id, status, allowedStatuses) {
574
+ let model;
575
+ await Model.transaction(async (transacting) => {
576
+ model = await Model.findOne({id}, {require: true, transacting, forUpdate: true});
577
+ if (!allowedStatuses.includes(model.get('status'))) {
578
+ model = undefined;
579
+ return;
580
+ }
581
+ await model.save({
582
+ status
583
+ }, {patch: true, transacting, autoRefresh: false});
584
+ });
585
+ return model;
586
+ }
587
+
588
+ /**
589
+ * @private
590
+ * Retry a function until it doesn't throw an error or the max retries / max time are reached.
591
+ * @template T
592
+ * @param {() => Promise<T>} func
593
+ * @param {object} options
594
+ * @param {string} options.description Used for logging
595
+ * @param {number} options.sleep time between each retry (ms), will get multiplied by the number of retries
596
+ * @param {number} options.maxRetries note: retries, not tries. So 0 means maximum 1 try, 1 means maximum 2 tries, etc.
597
+ * @param {number} [options.retryCount] (internal) Amount of retries already done. 0 intially.
598
+ * @param {number} [options.maxTime] (ms)
599
+ * @param {Date} [options.stopAfterDate]
600
+ * @returns {Promise<T>}
601
+ */
602
+ async retryDb(func, options) {
603
+ if (options.maxTime !== undefined) {
604
+ const stopAfterDate = new Date(Date.now() + options.maxTime);
605
+ if (!options.stopAfterDate || stopAfterDate < options.stopAfterDate) {
606
+ options = {...options, stopAfterDate};
607
+ }
608
+ }
609
+ const retryCount = (options.retryCount ?? 0);
610
+
611
+ try {
612
+ if (retryCount > 0) {
613
+ logging.info(`[BULK_EMAIL_DB_RETRY] ${options.description} - Retrying ${retryCount + 1}th try`);
614
+ } else {
615
+ logging.info(`[BULK_EMAIL_DB_RETRY] ${options.description} - Started (1st try)`);
616
+ }
617
+
618
+ const response = await func();
619
+
620
+ logging.info(`[BULK_EMAIL_DB_RETRY] ${options.description} - Finished (after ${retryCount + 1}${retryCount === 0 ? 'st try' : ' tries'})`);
621
+
622
+ return response;
623
+ } catch (e) {
624
+ const sleep = (options.sleep ?? 0);
625
+ if (retryCount >= options.maxRetries || (options.stopAfterDate && (new Date(Date.now() + sleep)) > options.stopAfterDate)) {
626
+ if (retryCount > 0) {
627
+ const ghostError = new errors.EmailError({
628
+ err: e,
629
+ code: 'BULK_EMAIL_DB_RETRY',
630
+ message: `[BULK_EMAIL_DB_RETRY] ${options.description} - Failed and stopped retrying: ${retryCount >= options.maxRetries ? 'max retries reached' : 'max time reached'}`,
631
+ context: e.message
632
+ });
633
+
634
+ logging.error(ghostError);
635
+ }
636
+ throw e;
637
+ }
638
+
639
+ const ghostError = new errors.EmailError({
640
+ err: e,
641
+ code: 'BULK_EMAIL_DB_RETRY',
642
+ message: `[BULK_EMAIL_DB_RETRY] ${options.description} - Failed (${retryCount + 1}${retryCount === 0 ? 'st' : 'th'} try)`,
643
+ context: e.message
644
+ });
645
+
646
+ logging.error(ghostError);
647
+
648
+ if (sleep) {
649
+ await new Promise((resolve) => {
650
+ setTimeout(resolve, sleep);
651
+ });
652
+ }
653
+ return await this.retryDb(func, {...options, retryCount: retryCount + 1, sleep: sleep * 2});
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Returns the sending deadline for an email
659
+ * Based on the email.created_at timestamp and the configured target delivery window
660
+ * @param {*} email
661
+ * @returns Date | undefined
662
+ */
663
+ getDeliveryDeadline(email) {
664
+ // Return undefined if targetDeliveryWindow is 0 (or less)
665
+ const targetDeliveryWindow = this.#sendingService.getTargetDeliveryWindow();
666
+ if (targetDeliveryWindow === undefined || targetDeliveryWindow <= 0) {
667
+ return undefined;
668
+ }
669
+ try {
670
+ const startTime = email.get('created_at');
671
+ const deadline = new Date(startTime.getTime() + targetDeliveryWindow);
672
+ return deadline;
673
+ } catch (err) {
674
+ return undefined;
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Adds deliverytimes to the passed in batches, based on the delivery deadline
680
+ * @param {Email} email - the email model to be sent
681
+ * @param {number} numBatches - the number of batches to be sent
682
+ */
683
+ calculateDeliveryTimes(email, numBatches) {
684
+ const deadline = this.getDeliveryDeadline(email);
685
+ const now = new Date();
686
+ // If there is no deadline (target delivery window is not set) or the deadline is in the past, delivery immediately
687
+ if (!deadline || now >= deadline) {
688
+ return new Array(numBatches).fill(undefined);
689
+ } else {
690
+ const timeToDeadline = deadline.getTime() - now.getTime();
691
+ const batchDelay = timeToDeadline / numBatches;
692
+ const deliveryTimes = [];
693
+ for (let i = 0; i < numBatches; i++) {
694
+ const delay = batchDelay * i;
695
+ const deliveryTime = new Date(now.getTime() + delay);
696
+ deliveryTimes.push(deliveryTime);
697
+ }
698
+ return deliveryTimes;
699
+ }
700
+ }
701
+ }
702
+
703
+ module.exports = BatchSendingService;