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,74 @@
1
+ const tpl = require('@tryghost/tpl');
2
+ const errors = require('@tryghost/errors');
3
+
4
+ const messages = {
5
+ noneFilterError: 'Cannot send email to "none" recipient filter',
6
+ newsletterVisibilityError: 'Unexpected visibility value "{value}". Use one of the valid: "members", "paid".'
7
+ };
8
+
9
+ /**
10
+ * @typedef {object} MembersRepository
11
+ * @prop {(options) => Promise<any>} list
12
+ */
13
+
14
+ class EmailSegmenter {
15
+ #membersRepository;
16
+
17
+ /**
18
+ *
19
+ * @param {object} dependencies
20
+ * @param {MembersRepository} dependencies.membersRepository
21
+ */
22
+ constructor({
23
+ membersRepository
24
+ }) {
25
+ this.#membersRepository = membersRepository;
26
+ }
27
+
28
+ getMemberFilterForSegment(newsletter, emailRecipientFilter, segment) {
29
+ const filter = [`newsletters.id:'${newsletter.id}'`, 'email_disabled:0'];
30
+
31
+ switch (emailRecipientFilter) {
32
+ case 'all':
33
+ break;
34
+ case 'none':
35
+ throw new errors.InternalServerError({
36
+ message: tpl(messages.noneFilterError)
37
+ });
38
+ default:
39
+ filter.push(`(${emailRecipientFilter})`);
40
+ break;
41
+ }
42
+
43
+ const visibility = newsletter.get('visibility');
44
+ switch (visibility) {
45
+ case 'members':
46
+ // No need to add a member status filter as the email is available to all members
47
+ break;
48
+ case 'paid':
49
+ filter.push(`status:-free`);
50
+ break;
51
+ default:
52
+ throw new errors.InternalServerError({
53
+ message: tpl(messages.newsletterVisibilityError, {
54
+ value: visibility
55
+ })
56
+ });
57
+ }
58
+
59
+ if (segment) {
60
+ filter.push(`(${segment})`);
61
+ }
62
+
63
+ return filter.join('+');
64
+ }
65
+
66
+ async getMembersCount(newsletter, emailRecipientFilter, segment) {
67
+ const filter = this.getMemberFilterForSegment(newsletter, emailRecipientFilter, segment);
68
+ const {meta: {pagination: {total: membersCount}}} = await this.#membersRepository.list({filter});
69
+
70
+ return membersCount;
71
+ }
72
+ }
73
+
74
+ module.exports = EmailSegmenter;
@@ -0,0 +1,310 @@
1
+ /* eslint-disable no-unused-vars */
2
+
3
+ /**
4
+ * @typedef {object} Post
5
+ * @typedef {object} Email
6
+ * @typedef {object} LimitService
7
+ * @typedef {{checkVerificationRequired(): Promise<boolean>}} VerificationTrigger
8
+ */
9
+
10
+ const BatchSendingService = require('./BatchSendingService');
11
+ const errors = require('@tryghost/errors');
12
+ const tpl = require('@tryghost/tpl');
13
+ const EmailRenderer = require('./EmailRenderer');
14
+ const EmailSegmenter = require('./EmailSegmenter');
15
+ const SendingService = require('./SendingService');
16
+ const logging = require('@tryghost/logging');
17
+
18
+ const messages = {
19
+ archivedNewsletterError: 'Cannot send email to archived newsletters',
20
+ missingNewsletterError: 'The post does not have a newsletter relation',
21
+ emailSendingDisabled: `Email sending is temporarily disabled because your account is currently in review. You should have an email about this from us already, but you can also reach us any time at support@ghost.org`,
22
+ retryEmailStatusError: 'Can only retry emails for published posts'
23
+ };
24
+
25
+ class EmailService {
26
+ #batchSendingService;
27
+ #sendingService;
28
+ #models;
29
+ #settingsCache;
30
+ #emailRenderer;
31
+ #emailSegmenter;
32
+ #limitService;
33
+ #membersRepository;
34
+ #verificationTrigger;
35
+ #emailAnalyticsJobs;
36
+
37
+ /**
38
+ *
39
+ * @param {object} dependencies
40
+ * @param {BatchSendingService} dependencies.batchSendingService
41
+ * @param {SendingService} dependencies.sendingService
42
+ * @param {object} dependencies.models
43
+ * @param {object} dependencies.models.Email
44
+ * @param {object} dependencies.settingsCache
45
+ * @param {EmailRenderer} dependencies.emailRenderer
46
+ * @param {EmailSegmenter} dependencies.emailSegmenter
47
+ * @param {LimitService} dependencies.limitService
48
+ * @param {object} dependencies.membersRepository
49
+ * @param {VerificationTrigger} dependencies.verificationTrigger
50
+ * @param {object} dependencies.emailAnalyticsJobs
51
+ */
52
+ constructor({
53
+ batchSendingService,
54
+ sendingService,
55
+ models,
56
+ settingsCache,
57
+ emailRenderer,
58
+ emailSegmenter,
59
+ limitService,
60
+ membersRepository,
61
+ verificationTrigger,
62
+ emailAnalyticsJobs
63
+ }) {
64
+ this.#batchSendingService = batchSendingService;
65
+ this.#models = models;
66
+ this.#settingsCache = settingsCache;
67
+ this.#emailRenderer = emailRenderer;
68
+ this.#emailSegmenter = emailSegmenter;
69
+ this.#limitService = limitService;
70
+ this.#membersRepository = membersRepository;
71
+ this.#sendingService = sendingService;
72
+ this.#verificationTrigger = verificationTrigger;
73
+ this.#emailAnalyticsJobs = emailAnalyticsJobs;
74
+ }
75
+
76
+ /**
77
+ * @private
78
+ */
79
+ async checkLimits(addedCount = 0) {
80
+ // Check host limit for allowed member count and throw error if over limit
81
+ // - do this even if it's a retry so that there's no way around the limit
82
+ if (this.#limitService.isLimited('members')) {
83
+ await this.#limitService.errorIfIsOverLimit('members');
84
+ }
85
+
86
+ // Check host limit for disabled emails or going over emails limit
87
+ if (this.#limitService.isLimited('emails')) {
88
+ await this.#limitService.errorIfWouldGoOverLimit('emails', {addedCount});
89
+ }
90
+
91
+ // Check if email verification is required
92
+ if (await this.#verificationTrigger.checkVerificationRequired()) {
93
+ throw new errors.HostLimitError({
94
+ message: tpl(messages.emailSendingDisabled)
95
+ });
96
+ }
97
+ }
98
+
99
+ /**
100
+ *
101
+ * @param {Post} post
102
+ * @returns {Promise<Email>}
103
+ */
104
+ async createEmail(post) {
105
+ let newsletter = await post.getLazyRelation('newsletter');
106
+ if (!newsletter) {
107
+ throw new errors.EmailError({
108
+ message: tpl(messages.missingNewsletterError)
109
+ });
110
+ }
111
+
112
+ if (newsletter.get('status') !== 'active') {
113
+ // A post might have been scheduled to an archived newsletter.
114
+ // Don't send it (people can't unsubscribe any longer).
115
+ throw new errors.EmailError({
116
+ message: tpl(messages.archivedNewsletterError)
117
+ });
118
+ }
119
+
120
+ const emailRecipientFilter = post.get('email_recipient_filter');
121
+ const emailCount = await this.#emailSegmenter.getMembersCount(newsletter, emailRecipientFilter);
122
+ await this.checkLimits(emailCount);
123
+
124
+ const email = await this.#models.Email.add({
125
+ post_id: post.id,
126
+ newsletter_id: newsletter.id,
127
+ status: 'pending',
128
+ submitted_at: new Date(),
129
+ track_opens: !!this.#settingsCache.get('email_track_opens'),
130
+ track_clicks: !!this.#settingsCache.get('email_track_clicks'),
131
+ feedback_enabled: !!newsletter.get('feedback_enabled'),
132
+ recipient_filter: emailRecipientFilter,
133
+ subject: this.#emailRenderer.getSubject(post),
134
+ from: this.#emailRenderer.getFromAddress(post, newsletter),
135
+ replyTo: this.#emailRenderer.getReplyToAddress(post, newsletter),
136
+ email_count: emailCount,
137
+ source: post.get('lexical') || post.get('mobiledoc'),
138
+ source_type: post.get('lexical') ? 'lexical' : 'mobiledoc'
139
+ });
140
+
141
+ try {
142
+ this.#batchSendingService.scheduleEmail(email);
143
+ } catch (e) {
144
+ await email.save({
145
+ status: 'failed',
146
+ error: e.message || 'Something went wrong while scheduling the email'
147
+ }, {patch: true});
148
+ }
149
+
150
+ // make sure recurring background analytics jobs are running once we have emails
151
+ try {
152
+ await this.#emailAnalyticsJobs.scheduleRecurringJobs(true);
153
+ } catch (e) {
154
+ logging.error(e);
155
+ }
156
+
157
+ return email;
158
+ }
159
+ async retryEmail(email) {
160
+ // Block accidentaly retrying non-published posts (can happen due to bugs in frontend)
161
+ const post = await email.getLazyRelation('post');
162
+ if (post.get('status') !== 'published' && post.get('status') !== 'sent') {
163
+ throw new errors.IncorrectUsageError({
164
+ message: tpl(messages.retryEmailStatusError)
165
+ });
166
+ }
167
+
168
+ await this.checkLimits();
169
+
170
+ // Change email status back to 'pending' before scheduling
171
+ // so we have a immediate response when retrying an email (schedule can take a while to kick off sometimes)
172
+ if (email.get('status') === 'failed') {
173
+ await email.save({status: 'pending'}, {patch: true});
174
+ }
175
+
176
+ this.#batchSendingService.scheduleEmail(email);
177
+ return email;
178
+ }
179
+
180
+ /**
181
+ * @params {string} [segment]
182
+ * @return {import('./EmailRenderer').MemberLike}
183
+ */
184
+ getDefaultExampleMember(segment) {
185
+ /**
186
+ * @type {import('./EmailRenderer').MemberLike}
187
+ */
188
+ return {
189
+ id: 'example-id',
190
+ uuid: 'example-uuid',
191
+ email: 'jamie@example.com',
192
+ name: 'Jamie Larson',
193
+ createdAt: new Date(),
194
+ status: segment === 'status:free' ? 'free' : 'paid',
195
+ subscriptions: segment === 'status:free' ? [] : [
196
+ {
197
+ cancel_at_period_end: false,
198
+ trial_end_at: null,
199
+ current_period_end: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
200
+ status: 'active'
201
+ }
202
+ ],
203
+ tiers: []
204
+ };
205
+ }
206
+
207
+ /**
208
+ * @private
209
+ * @param {string} [email] (optional) Search for a member with this email address and use it as the example. If not found, defaults to the default but still uses the provided email address.
210
+ * @param {string} [segment] (optional) The segment to use for the example member
211
+ * @return {Promise<import('./EmailRenderer').MemberLike>}
212
+ */
213
+ async getExampleMember(email, segment) {
214
+ /**
215
+ * @type {import('./EmailRenderer').MemberLike}
216
+ */
217
+ const exampleMember = this.getDefaultExampleMember(segment);
218
+
219
+ // fetch any matching members so that replacements use expected values
220
+ if (email) {
221
+ const member = await this.#membersRepository.get({email});
222
+ if (member) {
223
+ exampleMember.id = member.id;
224
+ exampleMember.uuid = member.get('uuid');
225
+ exampleMember.email = member.get('email');
226
+ exampleMember.name = member.get('name');
227
+ exampleMember.createdAt = member.get('created_at');
228
+
229
+ if (segment === 'status:-free' && member.get('status') !== 'free') {
230
+ // Make sure the example member matches the chosen segment (otherwise we'll send an email to free segment, but include a paid member details, which looks like a bug)
231
+ exampleMember.status = member.get('status');
232
+ const subscriptions = (await member.getLazyRelation('stripeSubscriptions')).toJSON();
233
+ exampleMember.subscriptions = subscriptions;
234
+
235
+ const tiers = (await member.getLazyRelation('products')).toJSON();
236
+ exampleMember.tiers = tiers;
237
+ }
238
+ } else {
239
+ exampleMember.name = ''; // Force empty name to simulate name fallbacks
240
+ exampleMember.email = email;
241
+ }
242
+ }
243
+
244
+ return exampleMember;
245
+ }
246
+
247
+ /**
248
+ * Do a manual replacement of tokens with values for a member (normally only used for previews)
249
+ *
250
+ * @param {string} htmlOrPlaintext
251
+ * @param {import('./EmailRenderer').ReplacementDefinition[]} replacements
252
+ * @param {import('./EmailRenderer').MemberLike} member
253
+ * @return {string}
254
+ */
255
+ replaceDefinitions(htmlOrPlaintext, replacements, member) {
256
+ // Do manual replacements with an example member
257
+ for (const replacement of replacements) {
258
+ htmlOrPlaintext = htmlOrPlaintext.replace(replacement.token, replacement.getValue(member));
259
+ }
260
+ return htmlOrPlaintext;
261
+ }
262
+
263
+ /**
264
+ *
265
+ * @param {*} post
266
+ * @param {*} newsletter
267
+ * @param {import('./EmailRenderer').Segment} segment
268
+ * @returns {Promise<{subject: string, html: string, plaintext: string}>} Email preview
269
+ */
270
+ async previewEmail(post, newsletter, segment) {
271
+ const exampleMember = await this.getExampleMember(null, segment);
272
+
273
+ const subject = this.#emailRenderer.getSubject(post);
274
+ let {html, plaintext, replacements} = await this.#emailRenderer.renderBody(post, newsletter, segment, {clickTrackingEnabled: false});
275
+
276
+ return {
277
+ subject,
278
+ html: this.replaceDefinitions(html, replacements, exampleMember),
279
+ plaintext: this.replaceDefinitions(plaintext, replacements, exampleMember)
280
+ };
281
+ }
282
+
283
+ /**
284
+ *
285
+ * @param {*} post
286
+ * @param {*} newsletter
287
+ * @param {import('./EmailRenderer').Segment} segment
288
+ * @param {string[]} emails
289
+ */
290
+ async sendTestEmail(post, newsletter, segment, emails) {
291
+ const members = [];
292
+ for (const email of emails) {
293
+ members.push(await this.getExampleMember(email, segment));
294
+ }
295
+
296
+ await this.#sendingService.send({
297
+ post,
298
+ newsletter,
299
+ segment,
300
+ members,
301
+ emailId: null
302
+ }, {
303
+ clickTrackingEnabled: false,
304
+ openTrackingEnabled: false,
305
+ isTestEmail: true
306
+ });
307
+ }
308
+ }
309
+
310
+ module.exports = EmailService;
@@ -15,7 +15,14 @@ class EmailServiceWrapper {
15
15
  return;
16
16
  }
17
17
 
18
- const {EmailService, EmailController, EmailRenderer, SendingService, BatchSendingService, EmailSegmenter, MailgunEmailProvider} = require('@tryghost/email-service');
18
+ const EmailService = require('./EmailService');
19
+ const EmailController = require('./EmailController');
20
+ const EmailRenderer = require('./EmailRenderer');
21
+ const SendingService = require('./SendingService');
22
+ const BatchSendingService = require('./BatchSendingService');
23
+ const EmailSegmenter = require('./EmailSegmenter');
24
+ const MailgunEmailProvider = require('./MailgunEmailProvider');
25
+
19
26
  const {Post, Newsletter, Email, EmailBatch, EmailRecipient, Member} = require('../../models');
20
27
  const MailgunClient = require('../lib/MailgunClient');
21
28
  const configService = require('../../../shared/config');
@@ -52,7 +59,7 @@ class EmailServiceWrapper {
52
59
  config: configService, settings: settingsCache
53
60
  });
54
61
  const i18nLanguage = labs.isSet('i18n') ? settingsCache.get('locale') || 'en' : 'en';
55
- const i18n = i18nLib(i18nLanguage, 'newsletter');
62
+ const i18n = i18nLib(i18nLanguage, 'ghost');
56
63
 
57
64
  events.on('settings.labs.edited', () => {
58
65
  if (labs.isSet('i18n')) {
@@ -0,0 +1,191 @@
1
+ const logging = require('@tryghost/logging');
2
+ const errors = require('@tryghost/errors');
3
+ const debug = require('@tryghost/debug')('email-service:mailgun-provider-service');
4
+
5
+ /**
6
+ * @typedef {object} Recipient
7
+ * @prop {string} email
8
+ * @prop {Replacement[]} replacements
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} Replacement
13
+ * @prop {string} token
14
+ * @prop {string} value
15
+ * @prop {string} id
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} EmailSendingOptions
20
+ * @prop {boolean} clickTrackingEnabled
21
+ * @prop {boolean} openTrackingEnabled
22
+ * @prop {Date} deliveryTime
23
+ */
24
+
25
+ /**
26
+ * @typedef {object} EmailProviderSuccessResponse
27
+ * @prop {string} id
28
+ */
29
+
30
+ class MailgunEmailProvider {
31
+ #mailgunClient;
32
+ #errorHandler;
33
+
34
+ /**
35
+ * @param {object} dependencies
36
+ * @param {import('@tryghost/mailgun-client/lib/MailgunClient')} dependencies.mailgunClient - mailgun client to send emails
37
+ * @param {Function} [dependencies.errorHandler] - custom error handler for logging exceptions
38
+ */
39
+ constructor({
40
+ mailgunClient,
41
+ errorHandler
42
+ }) {
43
+ this.#mailgunClient = mailgunClient;
44
+ this.#errorHandler = errorHandler;
45
+ }
46
+
47
+ #createRecipientData(replacements) {
48
+ let recipientData = {};
49
+
50
+ recipientData = replacements.reduce((acc, replacement) => {
51
+ const {id, value} = replacement;
52
+ acc[id] = value;
53
+ return acc;
54
+ }, {});
55
+
56
+ return recipientData;
57
+ }
58
+
59
+ #updateRecipientVariables(data, replacementDefinitions) {
60
+ for (const def of replacementDefinitions) {
61
+ data = data.replace(
62
+ def.token,
63
+ `%recipient.${def.id}%`
64
+ );
65
+ }
66
+ return data;
67
+ }
68
+
69
+ /**
70
+ * Create mailgun error message for storing in the database
71
+ * @param {Object} error
72
+ * @param {string} error.message
73
+ * @param {string} error.details
74
+ * @returns {string}
75
+ */
76
+ #createMailgunErrorMessage(error) {
77
+ const message = (error?.message || 'Mailgun Error') + (error?.details ? (': ' + error.details) : '');
78
+ return message.slice(0, 2000);
79
+ }
80
+
81
+ /**
82
+ * Send an email using the Mailgun API
83
+ * @param {import('./SendingService').EmailData} data
84
+ * @param {EmailSendingOptions} options
85
+ * @returns {Promise<EmailProviderSuccessResponse>}
86
+ */
87
+ async send(data, options) {
88
+ const {
89
+ subject,
90
+ html,
91
+ plaintext,
92
+ from,
93
+ replyTo,
94
+ emailId,
95
+ recipients,
96
+ replacementDefinitions
97
+ } = data;
98
+
99
+ logging.info(`Sending email to ${recipients.length} recipients`);
100
+ const startTime = Date.now();
101
+ debug(`sending message to ${recipients.length} recipients`);
102
+
103
+ try {
104
+ const messageData = {
105
+ subject,
106
+ html,
107
+ plaintext,
108
+ from,
109
+ replyTo,
110
+ id: emailId,
111
+ track_opens: !!options.openTrackingEnabled,
112
+ track_clicks: !!options.clickTrackingEnabled
113
+ };
114
+
115
+ if (options.deliveryTime && options.deliveryTime instanceof Date) {
116
+ messageData.deliveryTime = options.deliveryTime;
117
+ }
118
+
119
+ // create recipient data for Mailgun using replacement definitions
120
+ const recipientData = recipients.reduce((acc, recipient) => {
121
+ acc[recipient.email] = this.#createRecipientData(recipient.replacements);
122
+ return acc;
123
+ }, {});
124
+
125
+ // update content to use Mailgun variable syntax for all replacements
126
+ ['html', 'plaintext'].forEach((key) => {
127
+ if (messageData[key]) {
128
+ messageData[key] = this.#updateRecipientVariables(messageData[key], replacementDefinitions);
129
+ }
130
+ });
131
+
132
+ // send the email using Mailgun
133
+ // uses empty replacements array as we've already replaced all tokens with Mailgun variables
134
+ const response = await this.#mailgunClient.send(
135
+ messageData,
136
+ recipientData,
137
+ []
138
+ );
139
+
140
+ debug(`sent message (${Date.now() - startTime}ms)`);
141
+ logging.info(`Sent message (${Date.now() - startTime}ms)`);
142
+
143
+ // Return mailgun provider id, trim <> from response
144
+ return {
145
+ id: response.id.trim().replace(/^<|>$/g, '')
146
+ };
147
+ } catch (e) {
148
+ let ghostError;
149
+ if (e.error && e.messageData) {
150
+ const {error, messageData} = e;
151
+
152
+ // REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#status-codes
153
+ ghostError = new errors.EmailError({
154
+ statusCode: error.status,
155
+ message: this.#createMailgunErrorMessage(error),
156
+ errorDetails: JSON.stringify({error, messageData}),
157
+ context: `Mailgun Error ${error.status}: ${error.details}`,
158
+ help: `https://ghost.org/docs/newsletters/#bulk-email-configuration`,
159
+ code: 'BULK_EMAIL_SEND_FAILED'
160
+ });
161
+ } else {
162
+ ghostError = new errors.EmailError({
163
+ statusCode: undefined,
164
+ message: this.#createMailgunErrorMessage(e),
165
+ errorDetails: undefined,
166
+ context: e.context || 'Mailgun Error',
167
+ code: 'BULK_EMAIL_SEND_FAILED'
168
+ });
169
+ }
170
+
171
+ debug(`failed to send message (${Date.now() - startTime}ms)`);
172
+
173
+ throw ghostError;
174
+ }
175
+ }
176
+
177
+ getMaximumRecipients() {
178
+ return this.#mailgunClient.getBatchSize();
179
+ }
180
+
181
+ /**
182
+ * Returns the configured delay between batches in milliseconds
183
+ *
184
+ * @returns {number}
185
+ */
186
+ getTargetDeliveryWindow() {
187
+ return this.#mailgunClient.getTargetDeliveryWindow();
188
+ }
189
+ }
190
+
191
+ module.exports = MailgunEmailProvider;