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