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.
- package/components/tryghost-i18n-5.120.0.tgz +0 -0
- package/core/boot.js +0 -2
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +7555 -7216
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-60ce658c.mjs → CodeEditorView-1c5b0683.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-8480baa8.mjs → index-14e518a7.mjs} +3 -3
- package/core/built/admin/assets/admin-x-settings/{index-a2648c61.mjs → index-fc9f985b.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{modals-6900c1d5.mjs → modals-15bc6a0f.mjs} +7192 -6656
- package/core/built/admin/assets/{chunk.137.c9bf40f01afeeadb4660.js → chunk.383.25fca2f09b4896656125.js} +76 -59
- package/core/built/admin/assets/chunk.524.1657b12c0ab25dd9fb79.js +28 -0
- package/core/built/admin/assets/{chunk.582.2697b46a5652693fc674.js → chunk.582.09869b1f1a3cc0ab81f6.js} +19 -26
- package/core/built/admin/assets/{ghost-843572e9507d099162ae744d791daba1.js → ghost-b3b44421acca3b3eec76bfbb6ba0e81b.js} +3 -3
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +12578 -12352
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +423 -211
- package/core/built/admin/assets/posts/posts.js +13680 -13671
- package/core/built/admin/assets/stats/stats.js +16457 -16635
- package/core/built/admin/assets/{vendor-8f805740fee4db959a5b2119001a56b1.js → vendor-4ce6d282a2a00fe486a0951e0591da19.js} +11 -9
- package/core/built/admin/index.html +5 -5
- package/core/frontend/helpers/match.js +6 -0
- package/core/frontend/services/routing/ParentRouter.js +1 -1
- package/core/frontend/services/routing/controllers/email-post.js +0 -2
- package/core/frontend/services/routing/controllers/previews.js +0 -3
- package/core/frontend/web/middleware/frontend-caching.js +2 -2
- package/core/server/api/endpoints/authentication.js +37 -73
- package/core/server/api/endpoints/authors-public.js +8 -9
- package/core/server/api/endpoints/db.js +34 -35
- package/core/server/api/endpoints/emails.js +8 -10
- package/core/server/api/endpoints/integrations.js +20 -18
- package/core/server/api/endpoints/invites.js +8 -10
- package/core/server/api/endpoints/labels.js +19 -23
- package/core/server/api/endpoints/notifications.js +3 -4
- package/core/server/api/endpoints/pages-public.js +8 -10
- package/core/server/api/endpoints/pages.js +14 -18
- package/core/server/api/endpoints/posts-public.js +8 -10
- package/core/server/api/endpoints/posts.js +6 -8
- package/core/server/api/endpoints/previews.js +8 -10
- package/core/server/api/endpoints/redirects.js +7 -8
- package/core/server/api/endpoints/schedules.js +5 -7
- package/core/server/api/endpoints/slugs.js +7 -9
- package/core/server/api/endpoints/snippets.js +16 -20
- package/core/server/api/endpoints/tags-public.js +8 -10
- package/core/server/api/endpoints/tags.js +19 -23
- package/core/server/api/endpoints/themes.js +6 -8
- package/core/server/api/endpoints/users.js +31 -36
- package/core/server/api/endpoints/utils/permissions.js +10 -10
- package/core/server/api/endpoints/utils/serializers/output/roles.js +9 -10
- package/core/server/api/endpoints/utils/validators/input/images.js +43 -52
- package/core/server/api/endpoints/utils/validators/input/invites.js +6 -8
- package/core/server/api/endpoints/webhooks.js +38 -42
- package/core/server/data/migrations/versions/5.120/2025-05-07-14-57-38-add-newsletters-button-corners-column.js +8 -0
- package/core/server/data/migrations/versions/5.120/2025-05-13-17-36-56-add-newsletters-button-style-column.js +8 -0
- package/core/server/data/migrations/versions/5.120/2025-05-14-20-00-15-add-newsletters-setting-columns.js +22 -0
- package/core/server/data/schema/schema.js +6 -1
- package/core/server/lib/image/Gravatar.js +12 -13
- package/core/server/lib/lexical.js +3 -1
- package/core/server/models/newsletter.js +6 -1
- package/core/server/services/api-version-compatibility/index.js +1 -33
- package/core/server/services/auth/session/emails/signin.js +3 -3
- package/core/server/services/email-address/EmailAddressParser.js +52 -0
- package/core/server/services/email-address/EmailAddressParser.js.d.ts +13 -0
- package/core/server/services/email-address/EmailAddressService.js +142 -0
- package/core/server/services/email-address/EmailAddressService.ts +183 -0
- package/core/server/services/email-address/EmailAddressServiceWrapper.js +2 -4
- package/core/server/services/email-analytics/EmailAnalyticsService.js +1 -1
- package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +2 -1
- package/core/server/services/email-service/BatchSendingService.js +703 -0
- package/core/server/services/email-service/EmailBodyCache.js +20 -0
- package/core/server/services/email-service/EmailController.js +94 -0
- package/core/server/services/email-service/EmailEventProcessor.js +267 -0
- package/core/server/services/email-service/EmailEventStorage.js +187 -0
- package/core/server/services/email-service/EmailRenderer.js +1263 -0
- package/core/server/services/email-service/EmailSegmenter.js +74 -0
- package/core/server/services/email-service/EmailService.js +310 -0
- package/core/server/services/email-service/EmailServiceWrapper.js +9 -2
- package/core/server/services/email-service/MailgunEmailProvider.js +191 -0
- package/core/server/services/email-service/SendingService.js +173 -0
- package/core/server/services/email-service/email-templates/partials/feedback-button.hbs +7 -0
- package/core/server/services/email-service/email-templates/partials/latest-posts.hbs +39 -0
- package/core/server/services/email-service/email-templates/partials/paywall.hbs +20 -0
- package/core/server/services/email-service/email-templates/partials/styles.hbs +2348 -0
- package/core/server/services/email-service/email-templates/template.hbs +238 -0
- package/core/server/services/email-service/events/EmailBouncedEvent.js +63 -0
- package/core/server/services/email-service/events/EmailDeliveredEvent.js +49 -0
- package/core/server/services/email-service/events/EmailOpenedEvent.js +49 -0
- package/core/server/services/email-service/events/EmailTemporaryBouncedEvent.js +63 -0
- package/core/server/services/email-service/events/EmailUnsubscribedEvent.js +42 -0
- package/core/server/services/email-service/events/SpamComplaintEvent.js +42 -0
- package/core/server/services/email-service/helpers/register-helpers.js +59 -0
- package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +2 -1
- package/core/server/services/explore-ping/index.js +2 -1
- package/core/server/services/mail/GhostMailer.js +1 -1
- package/core/server/services/media-inliner/ExternalMediaInliner.js +2 -1
- package/core/server/services/members/api.js +15 -15
- package/core/server/services/members/emails/signin.js +4 -4
- package/core/server/services/members/emails/signup-paid.js +3 -4
- package/core/server/services/members/emails/signup.js +3 -3
- package/core/server/services/members/emails/subscribe.js +3 -3
- package/core/server/services/members/members-api/repositories/MemberRepository.js +92 -92
- package/core/server/services/members-events/LastSeenAtUpdater.js +1 -1
- package/core/server/services/settings-helpers/SettingsHelpers.js +1 -1
- package/core/server/services/staff/StaffServiceEmails.js +1 -1
- package/core/server/services/stats/PostsStatsService.js +28 -7
- package/core/server/web/api/app.js +0 -1
- package/core/server/web/api/endpoints/admin/app.js +0 -2
- package/core/server/web/api/endpoints/content/app.js +0 -2
- package/core/server/web/api/middleware/upload.js +2 -2
- package/core/shared/custom-theme-settings-cache/CustomThemeSettingsService.js +2 -1
- package/package.json +39 -97
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +385 -517
- package/components/tryghost-api-framework-5.119.3.tgz +0 -0
- package/components/tryghost-custom-fonts-5.119.3.tgz +0 -0
- package/components/tryghost-domain-events-5.119.3.tgz +0 -0
- package/components/tryghost-email-addresses-5.119.3.tgz +0 -0
- package/components/tryghost-email-service-5.119.3.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.119.3.tgz +0 -0
- package/components/tryghost-i18n-5.119.3.tgz +0 -0
- package/components/tryghost-job-manager-5.119.3.tgz +0 -0
- package/components/tryghost-members-csv-5.119.3.tgz +0 -0
- package/components/tryghost-mw-error-handler-5.119.3.tgz +0 -0
- package/components/tryghost-mw-vhost-5.119.3.tgz +0 -0
- package/components/tryghost-prometheus-metrics-5.119.3.tgz +0 -0
- package/components/tryghost-security-5.119.3.tgz +0 -0
- package/core/built/admin/assets/chunk.524.c86e2e1b3e94d7cb1e4c.js +0 -35
- package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +0 -99
- package/core/server/services/api-version-compatibility/VersionNotificationsDataService.js +0 -80
- package/core/server/services/api-version-compatibility/extract-api-key.js +0 -57
- package/core/server/services/api-version-compatibility/mw-api-version-mismatch.js +0 -31
- /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
|
|
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, '
|
|
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;
|