ghost 5.37.0 → 5.38.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-adapter-cache-memory-ttl-5.37.0.tgz → tryghost-adapter-cache-memory-ttl-5.38.0.tgz} +0 -0
- package/components/tryghost-adapter-cache-redis-5.38.0.tgz +0 -0
- package/components/{tryghost-adapter-manager-5.37.0.tgz → tryghost-adapter-manager-5.38.0.tgz} +0 -0
- package/components/{tryghost-api-framework-5.37.0.tgz → tryghost-api-framework-5.38.0.tgz} +0 -0
- package/components/{tryghost-api-version-compatibility-service-5.37.0.tgz → tryghost-api-version-compatibility-service-5.38.0.tgz} +0 -0
- package/components/tryghost-audience-feedback-5.38.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.38.0.tgz +0 -0
- package/components/{tryghost-constants-5.37.0.tgz → tryghost-constants-5.38.0.tgz} +0 -0
- package/components/tryghost-custom-theme-settings-service-5.38.0.tgz +0 -0
- package/components/{tryghost-data-generator-5.37.0.tgz → tryghost-data-generator-5.38.0.tgz} +0 -0
- package/components/tryghost-domain-events-5.38.0.tgz +0 -0
- package/components/tryghost-dynamic-routing-events-5.38.0.tgz +0 -0
- package/components/tryghost-email-analytics-provider-mailgun-5.38.0.tgz +0 -0
- package/components/{tryghost-email-analytics-service-5.37.0.tgz → tryghost-email-analytics-service-5.38.0.tgz} +0 -0
- package/components/tryghost-email-content-generator-5.38.0.tgz +0 -0
- package/components/tryghost-email-events-5.38.0.tgz +0 -0
- package/components/tryghost-email-service-5.38.0.tgz +0 -0
- package/components/{tryghost-email-suppression-list-5.37.0.tgz → tryghost-email-suppression-list-5.38.0.tgz} +0 -0
- package/components/tryghost-event-aware-cache-wrapper-5.38.0.tgz +0 -0
- package/components/{tryghost-express-dynamic-redirects-5.37.0.tgz → tryghost-express-dynamic-redirects-5.38.0.tgz} +0 -0
- package/components/tryghost-external-media-inliner-5.38.0.tgz +0 -0
- package/components/tryghost-extract-api-key-5.38.0.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.38.0.tgz +0 -0
- package/components/tryghost-i18n-5.38.0.tgz +0 -0
- package/components/{tryghost-importer-handler-content-files-5.37.0.tgz → tryghost-importer-handler-content-files-5.38.0.tgz} +0 -0
- package/components/tryghost-importer-revue-5.38.0.tgz +0 -0
- package/components/tryghost-job-manager-5.38.0.tgz +0 -0
- package/components/tryghost-link-redirects-5.38.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.38.0.tgz +0 -0
- package/components/{tryghost-link-tracking-5.37.0.tgz → tryghost-link-tracking-5.38.0.tgz} +0 -0
- package/components/{tryghost-magic-link-5.37.0.tgz → tryghost-magic-link-5.38.0.tgz} +0 -0
- package/components/tryghost-mailgun-client-5.38.0.tgz +0 -0
- package/components/tryghost-member-attribution-5.38.0.tgz +0 -0
- package/components/tryghost-member-events-5.38.0.tgz +0 -0
- package/components/tryghost-members-api-5.38.0.tgz +0 -0
- package/components/tryghost-members-csv-5.38.0.tgz +0 -0
- package/components/{tryghost-members-events-service-5.37.0.tgz → tryghost-members-events-service-5.38.0.tgz} +0 -0
- package/components/{tryghost-members-importer-5.37.0.tgz → tryghost-members-importer-5.38.0.tgz} +0 -0
- package/components/tryghost-members-offers-5.38.0.tgz +0 -0
- package/components/tryghost-members-payments-5.38.0.tgz +0 -0
- package/components/tryghost-members-ssr-5.38.0.tgz +0 -0
- package/components/{tryghost-members-stripe-service-5.37.0.tgz → tryghost-members-stripe-service-5.38.0.tgz} +0 -0
- package/components/tryghost-milestones-5.38.0.tgz +0 -0
- package/components/tryghost-minifier-5.38.0.tgz +0 -0
- package/components/tryghost-mw-api-version-mismatch-5.38.0.tgz +0 -0
- package/components/{tryghost-mw-cache-control-5.37.0.tgz → tryghost-mw-cache-control-5.38.0.tgz} +0 -0
- package/components/tryghost-mw-error-handler-5.38.0.tgz +0 -0
- package/components/tryghost-mw-session-from-token-5.38.0.tgz +0 -0
- package/components/tryghost-mw-update-user-last-seen-5.38.0.tgz +0 -0
- package/components/tryghost-mw-version-match-5.38.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.38.0.tgz +0 -0
- package/components/tryghost-oembed-service-5.38.0.tgz +0 -0
- package/components/tryghost-package-json-5.38.0.tgz +0 -0
- package/components/{tryghost-referrers-5.37.0.tgz → tryghost-referrers-5.38.0.tgz} +0 -0
- package/components/tryghost-security-5.38.0.tgz +0 -0
- package/components/tryghost-session-service-5.38.0.tgz +0 -0
- package/components/tryghost-settings-path-manager-5.38.0.tgz +0 -0
- package/components/tryghost-slack-notifications-5.38.0.tgz +0 -0
- package/components/tryghost-staff-service-5.38.0.tgz +0 -0
- package/components/tryghost-stats-service-5.38.0.tgz +0 -0
- package/components/tryghost-tiers-5.38.0.tgz +0 -0
- package/components/{tryghost-update-check-service-5.37.0.tgz → tryghost-update-check-service-5.38.0.tgz} +0 -0
- package/components/tryghost-verification-trigger-5.38.0.tgz +0 -0
- package/components/{tryghost-version-notifications-data-service-5.37.0.tgz → tryghost-version-notifications-data-service-5.38.0.tgz} +0 -0
- package/components/tryghost-webmentions-5.38.0.tgz +0 -0
- package/core/boot.js +11 -4
- package/core/built/admin/assets/{chunk.143.27cd10a38f877e715b35.js → chunk.143.c6802c882a911797ce4f.js} +6 -6
- package/core/built/admin/assets/{chunk.178.dd6cf17fb0986acf19d6.js → chunk.178.09faefd4027fcba4113d.js} +4 -4
- package/core/built/admin/assets/{chunk.652.bb618bc5abf23bed4e87.js → chunk.220.9ca2950240aba3fced21.js} +1836 -1774
- package/core/built/admin/assets/{chunk.79.4a959c324df25480b90e.js → chunk.79.acb7dd01e1c785f4920c.js} +12 -11
- package/core/built/admin/assets/{ghost-2948791640be026b987b88f89034bc85.js → ghost-35103ff053c43f1dfa7f35821c3c2412.js} +29 -29
- package/core/built/admin/assets/{ghost-efbe4dcc249d119a955b038aae5c980d.css → ghost-a9307c9cfe26a4bc621e02cd3bae421a.css} +1 -1
- package/core/built/admin/assets/{ghost-dark-6ea4b338f17a43c204b7c1e207b90cd7.css → ghost-dark-f309cf445255344e4861a95ecb8f1920.css} +1 -1
- package/core/built/admin/assets/vendor-b982e3bf1020bff77b2a3c44d5f59e55.js +269 -269
- package/core/built/admin/index.html +5 -5
- package/core/frontend/helpers/ghost_head.js +4 -1
- package/core/frontend/services/routing/StaticPagesRouter.js +1 -1
- package/core/frontend/services/sitemap/base-generator.js +5 -1
- package/core/server/adapters/storage/LocalImagesStorage.js +1 -1
- package/core/server/api/endpoints/email-previews.js +2 -43
- package/core/server/api/endpoints/emails.js +1 -22
- package/core/server/api/endpoints/utils/serializers/output/mappers/emails.js +14 -8
- package/core/server/data/importer/import-manager.js +8 -1
- package/core/server/data/migrations/versions/4.9/05-fix-missed-mobiledoc-url-transforms.js +1 -1
- package/core/server/lib/common/events.js +16 -23
- package/core/server/models/base/plugins/relations.js +5 -3
- package/core/server/models/index.js +5 -0
- package/core/server/services/comments/emails.js +2 -2
- package/core/server/services/email-service/wrapper.js +2 -0
- package/core/server/services/link-tracking/LinkClickRepository.js +1 -1
- package/core/server/services/media-inliner/service.js +49 -3
- package/core/server/services/mentions/service.js +6 -1
- package/core/server/services/posts/posts-service.js +3 -14
- package/core/server/services/staff/index.js +2 -0
- package/core/server/services/url/Urls.js +10 -2
- package/core/shared/labs.js +0 -1
- package/package.json +138 -138
- package/yarn.lock +267 -259
- package/components/tryghost-adapter-cache-redis-5.37.0.tgz +0 -0
- package/components/tryghost-audience-feedback-5.37.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.37.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.37.0.tgz +0 -0
- package/components/tryghost-domain-events-5.37.0.tgz +0 -0
- package/components/tryghost-dynamic-routing-events-5.37.0.tgz +0 -0
- package/components/tryghost-email-analytics-provider-mailgun-5.37.0.tgz +0 -0
- package/components/tryghost-email-content-generator-5.37.0.tgz +0 -0
- package/components/tryghost-email-events-5.37.0.tgz +0 -0
- package/components/tryghost-email-service-5.37.0.tgz +0 -0
- package/components/tryghost-event-aware-cache-wrapper-5.37.0.tgz +0 -0
- package/components/tryghost-external-media-inliner-5.37.0.tgz +0 -0
- package/components/tryghost-extract-api-key-5.37.0.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.37.0.tgz +0 -0
- package/components/tryghost-i18n-5.37.0.tgz +0 -0
- package/components/tryghost-importer-revue-5.37.0.tgz +0 -0
- package/components/tryghost-job-manager-5.37.0.tgz +0 -0
- package/components/tryghost-link-redirects-5.37.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.37.0.tgz +0 -0
- package/components/tryghost-mailgun-client-5.37.0.tgz +0 -0
- package/components/tryghost-member-attribution-5.37.0.tgz +0 -0
- package/components/tryghost-member-events-5.37.0.tgz +0 -0
- package/components/tryghost-members-api-5.37.0.tgz +0 -0
- package/components/tryghost-members-csv-5.37.0.tgz +0 -0
- package/components/tryghost-members-offers-5.37.0.tgz +0 -0
- package/components/tryghost-members-payments-5.37.0.tgz +0 -0
- package/components/tryghost-members-ssr-5.37.0.tgz +0 -0
- package/components/tryghost-milestones-5.37.0.tgz +0 -0
- package/components/tryghost-minifier-5.37.0.tgz +0 -0
- package/components/tryghost-mw-api-version-mismatch-5.37.0.tgz +0 -0
- package/components/tryghost-mw-error-handler-5.37.0.tgz +0 -0
- package/components/tryghost-mw-session-from-token-5.37.0.tgz +0 -0
- package/components/tryghost-mw-update-user-last-seen-5.37.0.tgz +0 -0
- package/components/tryghost-mw-version-match-5.37.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.37.0.tgz +0 -0
- package/components/tryghost-oembed-service-5.37.0.tgz +0 -0
- package/components/tryghost-package-json-5.37.0.tgz +0 -0
- package/components/tryghost-security-5.37.0.tgz +0 -0
- package/components/tryghost-session-service-5.37.0.tgz +0 -0
- package/components/tryghost-settings-path-manager-5.37.0.tgz +0 -0
- package/components/tryghost-slack-notifications-5.37.0.tgz +0 -0
- package/components/tryghost-staff-service-5.37.0.tgz +0 -0
- package/components/tryghost-stats-service-5.37.0.tgz +0 -0
- package/components/tryghost-tiers-5.37.0.tgz +0 -0
- package/components/tryghost-verification-trigger-5.37.0.tgz +0 -0
- package/components/tryghost-webmentions-5.37.0.tgz +0 -0
- package/core/server/services/bulk-email/bulk-email-processor.js +0 -289
- package/core/server/services/bulk-email/index.js +0 -1
- package/core/server/services/mega/email-preview.js +0 -54
- package/core/server/services/mega/feedback-buttons.js +0 -66
- package/core/server/services/mega/index.js +0 -14
- package/core/server/services/mega/mega.js +0 -626
- package/core/server/services/mega/post-email-serializer.js +0 -559
- package/core/server/services/mega/segment-parser.js +0 -20
- package/core/server/services/mega/template.js +0 -1319
- /package/core/built/admin/assets/{chunk.652.bb618bc5abf23bed4e87.js.LICENSE.txt → chunk.220.9ca2950240aba3fced21.js.LICENSE.txt} +0 -0
|
@@ -1,626 +0,0 @@
|
|
|
1
|
-
const _ = require('lodash');
|
|
2
|
-
const Promise = require('bluebird');
|
|
3
|
-
const debug = require('@tryghost/debug')('mega');
|
|
4
|
-
const tpl = require('@tryghost/tpl');
|
|
5
|
-
const moment = require('moment');
|
|
6
|
-
const ObjectID = require('bson-objectid').default;
|
|
7
|
-
const errors = require('@tryghost/errors');
|
|
8
|
-
const logging = require('@tryghost/logging');
|
|
9
|
-
const settingsCache = require('../../../shared/settings-cache');
|
|
10
|
-
const membersService = require('../members');
|
|
11
|
-
const limitService = require('../limits');
|
|
12
|
-
const bulkEmailService = require('../bulk-email');
|
|
13
|
-
const jobsService = require('../jobs');
|
|
14
|
-
const db = require('../../data/db');
|
|
15
|
-
const models = require('../../models');
|
|
16
|
-
const postEmailSerializer = require('./post-email-serializer');
|
|
17
|
-
const {getSegmentsFromHtml} = require('./segment-parser');
|
|
18
|
-
const labs = require('../../../shared/labs');
|
|
19
|
-
|
|
20
|
-
// Used to listen to email.added and email.edited model events originally, I think to offload this - ideally would just use jobs now if possible
|
|
21
|
-
const events = require('../../lib/common/events');
|
|
22
|
-
|
|
23
|
-
const messages = {
|
|
24
|
-
invalidSegment: 'Invalid segment value. Use one of the valid:"status:free" or "status:-free" values.',
|
|
25
|
-
unexpectedFilterError: 'Unexpected {property} value "{value}", expected an NQL equivalent',
|
|
26
|
-
noneFilterError: 'Cannot send email to "none" {property}',
|
|
27
|
-
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`,
|
|
28
|
-
sendEmailRequestFailed: 'The email service was unable to send an email batch.',
|
|
29
|
-
archivedNewsletterError: 'Cannot send email to archived newsletters',
|
|
30
|
-
newsletterVisibilityError: 'Unexpected visibility value "{value}". Use one of the valid: "members", "paid".'
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const getFromAddress = (senderName, fromAddress) => {
|
|
34
|
-
if (/@localhost$/.test(fromAddress) || /@ghost.local$/.test(fromAddress)) {
|
|
35
|
-
const localAddress = 'localhost@example.com';
|
|
36
|
-
logging.warn(`Rewriting bulk email from address ${fromAddress} to ${localAddress}`);
|
|
37
|
-
fromAddress = localAddress;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return senderName ? `"${senderName}"<${fromAddress}>` : fromAddress;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const getReplyToAddress = (fromAddress, replyAddressOption) => {
|
|
44
|
-
const supportAddress = membersService.config.getEmailSupportAddress();
|
|
45
|
-
|
|
46
|
-
return (replyAddressOption === 'support') ? supportAddress : fromAddress;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
* @param {Object} postModel - post model instance
|
|
52
|
-
* @param {Object} options
|
|
53
|
-
* @param {Object} options
|
|
54
|
-
*/
|
|
55
|
-
const getEmailData = async (postModel, options) => {
|
|
56
|
-
let newsletter;
|
|
57
|
-
if (options.newsletterSlug) {
|
|
58
|
-
newsletter = await models.Newsletter.findOne({slug: options.newsletterSlug});
|
|
59
|
-
} else {
|
|
60
|
-
newsletter = await postModel.getLazyRelation('newsletter');
|
|
61
|
-
}
|
|
62
|
-
if (!newsletter) {
|
|
63
|
-
// The postModel doesn't have a newsletter in test emails
|
|
64
|
-
newsletter = await models.Newsletter.getDefaultNewsletter();
|
|
65
|
-
}
|
|
66
|
-
const {subject, html, plaintext} = await postEmailSerializer.serialize(postModel, newsletter, options);
|
|
67
|
-
|
|
68
|
-
let senderName = settingsCache.get('title') ? settingsCache.get('title').replace(/"/g, '\\"') : '';
|
|
69
|
-
if (newsletter.get('sender_name')) {
|
|
70
|
-
senderName = newsletter.get('sender_name');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
let fromAddress = membersService.config.getEmailFromAddress();
|
|
74
|
-
if (newsletter.get('sender_email')) {
|
|
75
|
-
fromAddress = newsletter.get('sender_email');
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
post: postModel.toJSON(), // for content paywalling
|
|
80
|
-
subject,
|
|
81
|
-
html,
|
|
82
|
-
plaintext,
|
|
83
|
-
from: getFromAddress(senderName, fromAddress),
|
|
84
|
-
replyTo: getReplyToAddress(fromAddress, newsletter.get('sender_reply_to'))
|
|
85
|
-
};
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
90
|
-
* @param {Object} postModel - post model instance
|
|
91
|
-
* @param {[string]} toEmails - member email addresses to send email to
|
|
92
|
-
* @param {ValidMemberSegment} [memberSegment]
|
|
93
|
-
*/
|
|
94
|
-
const sendTestEmail = async (postModel, toEmails, memberSegment, newsletterSlug) => {
|
|
95
|
-
let emailData = await getEmailData(postModel, {isTestEmail: true, newsletterSlug});
|
|
96
|
-
emailData.subject = `[Test] ${emailData.subject}`;
|
|
97
|
-
|
|
98
|
-
// fetch any matching members so that replacements use expected values
|
|
99
|
-
const recipients = await Promise.all(toEmails.map(async (email) => {
|
|
100
|
-
const member = await membersService.api.members.get({email});
|
|
101
|
-
if (member) {
|
|
102
|
-
return {
|
|
103
|
-
member_uuid: member.get('uuid'),
|
|
104
|
-
member_email: member.get('email'),
|
|
105
|
-
member_name: member.get('name')
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return {
|
|
110
|
-
member_email: email
|
|
111
|
-
};
|
|
112
|
-
}));
|
|
113
|
-
|
|
114
|
-
// enable tracking for previews to match real-world behavior
|
|
115
|
-
emailData.track_opens = !!settingsCache.get('email_track_opens');
|
|
116
|
-
|
|
117
|
-
const response = await bulkEmailService.send(emailData, recipients, memberSegment);
|
|
118
|
-
|
|
119
|
-
if (response instanceof bulkEmailService.FailedBatch) {
|
|
120
|
-
return Promise.reject(response.error);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (response && response[0] && response[0].error) {
|
|
124
|
-
return Promise.reject(new errors.EmailError({
|
|
125
|
-
statusCode: response[0].error.statusCode,
|
|
126
|
-
message: response[0].error.message,
|
|
127
|
-
context: response[0].error.originalMessage
|
|
128
|
-
}));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return response;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* transformRecipientFilter
|
|
136
|
-
*
|
|
137
|
-
* Accepts a filter string, errors on unexpected legacy filter syntax and enforces subscribed:true
|
|
138
|
-
*
|
|
139
|
-
* @param {Object} newsletter
|
|
140
|
-
* @param {string} emailRecipientFilter NQL filter for members
|
|
141
|
-
* @param {string} errorProperty
|
|
142
|
-
*/
|
|
143
|
-
const transformEmailRecipientFilter = (newsletter, emailRecipientFilter, errorProperty) => {
|
|
144
|
-
const filter = [`newsletters.id:${newsletter.id}`];
|
|
145
|
-
|
|
146
|
-
switch (emailRecipientFilter) {
|
|
147
|
-
case 'all':
|
|
148
|
-
break;
|
|
149
|
-
case 'none':
|
|
150
|
-
throw new errors.InternalServerError({
|
|
151
|
-
message: tpl(messages.noneFilterError, {
|
|
152
|
-
property: errorProperty
|
|
153
|
-
})
|
|
154
|
-
});
|
|
155
|
-
default:
|
|
156
|
-
filter.push(`(${emailRecipientFilter})`);
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const visibility = newsletter.get('visibility');
|
|
161
|
-
switch (visibility) {
|
|
162
|
-
case 'members':
|
|
163
|
-
// No need to add a member status filter as the email is available to all members
|
|
164
|
-
break;
|
|
165
|
-
case 'paid':
|
|
166
|
-
filter.push(`status:-free`);
|
|
167
|
-
break;
|
|
168
|
-
default:
|
|
169
|
-
throw new errors.InternalServerError({
|
|
170
|
-
message: tpl(messages.newsletterVisibilityError, {
|
|
171
|
-
value: visibility
|
|
172
|
-
})
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return filter.join('+');
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* addEmail
|
|
181
|
-
*
|
|
182
|
-
* Accepts a post model and creates an email record based on it. Only creates one
|
|
183
|
-
* record per post
|
|
184
|
-
*
|
|
185
|
-
* @param {object} postModel Post Model Object
|
|
186
|
-
* @param {object} options
|
|
187
|
-
*/
|
|
188
|
-
|
|
189
|
-
const addEmail = async (postModel, options) => {
|
|
190
|
-
if (limitService.isLimited('emails')) {
|
|
191
|
-
await limitService.errorIfWouldGoOverLimit('emails');
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (await membersService.verificationTrigger.checkVerificationRequired()) {
|
|
195
|
-
throw new errors.HostLimitError({
|
|
196
|
-
message: tpl(messages.emailSendingDisabled)
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
|
|
201
|
-
const filterOptions = {...knexOptions, limit: 1};
|
|
202
|
-
const sharedOptions = _.pick(options, ['transacting']);
|
|
203
|
-
const newsletter = await postModel.getLazyRelation('newsletter', {require: true, ...sharedOptions});
|
|
204
|
-
|
|
205
|
-
if (newsletter.get('status') !== 'active') {
|
|
206
|
-
// A post might have been scheduled to an archived newsletter.
|
|
207
|
-
// Don't send it (people can't unsubscribe any longer).
|
|
208
|
-
throw new errors.EmailError({
|
|
209
|
-
message: tpl(messages.archivedNewsletterError)
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const emailRecipientFilter = postModel.get('email_recipient_filter');
|
|
214
|
-
filterOptions.filter = transformEmailRecipientFilter(newsletter, emailRecipientFilter, 'email_segment');
|
|
215
|
-
|
|
216
|
-
const startRetrieve = Date.now();
|
|
217
|
-
debug('addEmail: retrieving members count');
|
|
218
|
-
const {meta: {pagination: {total: membersCount}}} = await membersService.api.members.list({...knexOptions, ...filterOptions});
|
|
219
|
-
debug(`addEmail: retrieved members count - ${membersCount} members (${Date.now() - startRetrieve}ms)`);
|
|
220
|
-
|
|
221
|
-
// NOTE: don't create email object when there's nobody to send the email to
|
|
222
|
-
if (membersCount === 0) {
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (limitService.isLimited('emails')) {
|
|
227
|
-
await limitService.errorIfWouldGoOverLimit('emails', {addedCount: membersCount});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const postId = postModel.get('id');
|
|
231
|
-
const existing = await models.Email.findOne({post_id: postId}, knexOptions);
|
|
232
|
-
|
|
233
|
-
if (!existing) {
|
|
234
|
-
// get email contents and perform replacements using no member data so
|
|
235
|
-
// we have a decent snapshot of email content for later display
|
|
236
|
-
const emailData = await getEmailData(postModel, options);
|
|
237
|
-
|
|
238
|
-
return models.Email.add({
|
|
239
|
-
post_id: postId,
|
|
240
|
-
status: 'pending',
|
|
241
|
-
email_count: membersCount,
|
|
242
|
-
subject: emailData.subject,
|
|
243
|
-
from: emailData.from,
|
|
244
|
-
reply_to: emailData.replyTo,
|
|
245
|
-
html: emailData.html,
|
|
246
|
-
source: emailData.html,
|
|
247
|
-
source_type: 'html',
|
|
248
|
-
plaintext: emailData.plaintext,
|
|
249
|
-
submitted_at: moment().toDate(),
|
|
250
|
-
track_opens: !!settingsCache.get('email_track_opens'),
|
|
251
|
-
track_clicks: !!settingsCache.get('email_track_clicks'),
|
|
252
|
-
feedback_enabled: !!newsletter.get('feedback_enabled'),
|
|
253
|
-
recipient_filter: emailRecipientFilter,
|
|
254
|
-
newsletter_id: newsletter.id
|
|
255
|
-
}, knexOptions);
|
|
256
|
-
} else {
|
|
257
|
-
return existing;
|
|
258
|
-
}
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* retryFailedEmail
|
|
263
|
-
*
|
|
264
|
-
* Accepts an Email model and resets it's fields to trigger retry listeners
|
|
265
|
-
*
|
|
266
|
-
* @param {Email} emailModel Email model
|
|
267
|
-
*/
|
|
268
|
-
const retryFailedEmail = async (emailModel) => {
|
|
269
|
-
return await models.Email.edit({
|
|
270
|
-
status: 'pending'
|
|
271
|
-
}, {
|
|
272
|
-
id: emailModel.get('id')
|
|
273
|
-
});
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
async function pendingEmailHandler(emailModel, options) {
|
|
277
|
-
if (labs.isSet('emailStability')) {
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// CASE: do not send email if we import a database
|
|
282
|
-
// TODO: refactor post.published events to never fire on importing
|
|
283
|
-
if (options && options.importing) {
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (emailModel.get('status') !== 'pending') {
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// make sure recurring background analytics jobs are running once we have emails
|
|
292
|
-
const emailAnalyticsJobs = require('../email-analytics/jobs');
|
|
293
|
-
emailAnalyticsJobs.scheduleRecurringJobs();
|
|
294
|
-
|
|
295
|
-
// @TODO move this into the jobService
|
|
296
|
-
if (!process.env.NODE_ENV.startsWith('test')) {
|
|
297
|
-
return jobsService.addJob({
|
|
298
|
-
job: sendEmailJob,
|
|
299
|
-
data: {emailId: emailModel.id},
|
|
300
|
-
offloaded: false
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async function sendEmailJob({emailId, options}) {
|
|
306
|
-
logging.info('[sendEmailJob] Started for ' + emailId);
|
|
307
|
-
let startEmailSend = null;
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
// Check host limit for allowed member count and throw error if over limit
|
|
311
|
-
// - do this even if it's a retry so that there's no way around the limit
|
|
312
|
-
if (limitService.isLimited('members')) {
|
|
313
|
-
await limitService.errorIfIsOverLimit('members');
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Check host limit for disabled emails or going over emails limit
|
|
317
|
-
if (limitService.isLimited('emails')) {
|
|
318
|
-
await limitService.errorIfWouldGoOverLimit('emails');
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Check email verification required
|
|
322
|
-
// We need to check this inside the job again
|
|
323
|
-
if (await membersService.verificationTrigger.checkVerificationRequired()) {
|
|
324
|
-
throw new errors.HostLimitError({
|
|
325
|
-
message: tpl(messages.emailSendingDisabled)
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Check if the email is still pending. And set the status to submitting in one transaction.
|
|
330
|
-
let hasSingleAccess = false;
|
|
331
|
-
let emailModel;
|
|
332
|
-
await models.Base.transaction(async (transacting) => {
|
|
333
|
-
const knexOptions = {...options, transacting, forUpdate: true};
|
|
334
|
-
emailModel = await models.Email.findOne({id: emailId}, knexOptions);
|
|
335
|
-
|
|
336
|
-
if (!emailModel) {
|
|
337
|
-
throw new errors.IncorrectUsageError({
|
|
338
|
-
message: 'Provided email id does not match a known email record',
|
|
339
|
-
context: {
|
|
340
|
-
id: emailId
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (emailModel.get('status') !== 'pending') {
|
|
346
|
-
// We don't throw this, because we don't want to mark this email as failed
|
|
347
|
-
logging.error(new errors.IncorrectUsageError({
|
|
348
|
-
message: 'Emails can only be processed when in the "pending" state',
|
|
349
|
-
context: `Email "${emailId}" has state "${emailModel.get('status')}"`,
|
|
350
|
-
code: 'EMAIL_NOT_PENDING'
|
|
351
|
-
}));
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
await emailModel.save({status: 'submitting'}, Object.assign({}, knexOptions, {patch: true}));
|
|
356
|
-
hasSingleAccess = true;
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
if (!hasSingleAccess || !emailModel) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Create email batch and recipient rows unless this is a retry and they already exist
|
|
364
|
-
const existingBatchCount = await emailModel.related('emailBatches').count('id');
|
|
365
|
-
|
|
366
|
-
if (existingBatchCount === 0) {
|
|
367
|
-
logging.info('[sendEmailJob] Creating new batches for ' + emailId);
|
|
368
|
-
let newBatchCount = 0;
|
|
369
|
-
|
|
370
|
-
await models.Base.transaction(async (transacting) => {
|
|
371
|
-
const emailBatches = await createSegmentedEmailBatches({emailModel, options: {transacting}});
|
|
372
|
-
newBatchCount = emailBatches.length;
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
if (newBatchCount === 0) {
|
|
376
|
-
logging.info('[sendEmailJob] No batches created for ' + emailId);
|
|
377
|
-
await emailModel.save({status: 'submitted'}, {patch: true});
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
debug('sendEmailJob: sending email');
|
|
383
|
-
startEmailSend = Date.now();
|
|
384
|
-
await bulkEmailService.processEmail({emailModel, options});
|
|
385
|
-
debug(`sendEmailJob: sent email (${Date.now() - startEmailSend}ms)`);
|
|
386
|
-
} catch (error) {
|
|
387
|
-
if (startEmailSend) {
|
|
388
|
-
logging.info(`[sendEmailJob] Failed sending ${emailId} (${Date.now() - startEmailSend}ms)`);
|
|
389
|
-
} else {
|
|
390
|
-
logging.info(`[sendEmailJob] Failed sending ${emailId}`);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (startEmailSend) {
|
|
394
|
-
debug(`sendEmailJob: send email failed (${Date.now() - startEmailSend}ms)`);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
let errorMessage = error.message;
|
|
398
|
-
if (errorMessage.length > 2000) {
|
|
399
|
-
errorMessage = errorMessage.substring(0, 2000);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
await models.Email.edit({
|
|
403
|
-
status: 'failed',
|
|
404
|
-
error: errorMessage
|
|
405
|
-
}, {id: emailId});
|
|
406
|
-
|
|
407
|
-
throw new errors.InternalServerError({
|
|
408
|
-
err: error,
|
|
409
|
-
context: tpl(messages.sendEmailRequestFailed)
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Fetch rows of members that should receive an email.
|
|
416
|
-
* Uses knex directly rather than bookshelf to avoid thousands of bookshelf model
|
|
417
|
-
* instantiations and associated processing and event loop blocking
|
|
418
|
-
*
|
|
419
|
-
* @param {Object} options
|
|
420
|
-
* @param {Object} options.emailModel - instance of Email model
|
|
421
|
-
* @param {string} [options.memberSegment] - NQL filter to apply in addition to the one defined in emailModel
|
|
422
|
-
* @param {Object} options.options - knex options
|
|
423
|
-
*
|
|
424
|
-
* @returns {Promise<Object[]>} instances of filtered knex member rows
|
|
425
|
-
*/
|
|
426
|
-
async function getEmailMemberRows({emailModel, memberSegment, options}) {
|
|
427
|
-
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
|
|
428
|
-
const sharedOptions = _.pick(options, ['transacting']);
|
|
429
|
-
const filterOptions = Object.assign({}, knexOptions);
|
|
430
|
-
|
|
431
|
-
const newsletter = await emailModel.getLazyRelation('newsletter', {require: true, ...sharedOptions});
|
|
432
|
-
const recipientFilter = transformEmailRecipientFilter(newsletter, emailModel.get('recipient_filter'), 'recipient_filter');
|
|
433
|
-
filterOptions.filter = recipientFilter;
|
|
434
|
-
|
|
435
|
-
if (memberSegment) {
|
|
436
|
-
filterOptions.filter = `${filterOptions.filter}+${memberSegment}`;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const startRetrieve = Date.now();
|
|
440
|
-
debug('getEmailMemberRows: retrieving members list');
|
|
441
|
-
// select('members.*') is necessary here to avoid duplicate `email` columns in the result set
|
|
442
|
-
// without it we do `select *` which pulls in the Stripe customer email too which overrides the member email
|
|
443
|
-
const memberRows = await models.Member.getFilteredCollectionQuery(filterOptions).select('members.*').distinct();
|
|
444
|
-
debug(`getEmailMemberRows: retrieved members list - ${memberRows.length} members (${Date.now() - startRetrieve}ms)`);
|
|
445
|
-
|
|
446
|
-
return memberRows;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Partitions array of member records according to the segment they belong to
|
|
451
|
-
*
|
|
452
|
-
* @param {Object[]} memberRows raw member rows to partition
|
|
453
|
-
* @param {string[]} segments segment filters to partition batches by
|
|
454
|
-
*
|
|
455
|
-
* @returns {Object} partitioned memberRows with keys that correspond segment names
|
|
456
|
-
*/
|
|
457
|
-
function partitionMembersBySegment(memberRows, segments) {
|
|
458
|
-
const partitions = {};
|
|
459
|
-
|
|
460
|
-
for (const memberSegment of segments) {
|
|
461
|
-
let segmentedMemberRows;
|
|
462
|
-
|
|
463
|
-
// NOTE: because we only support two types of segments at the moment the logic was kept dead simple
|
|
464
|
-
// in the future this segmentation should probably be substituted with NQL:
|
|
465
|
-
// memberRows.filter(member => nql(memberSegment).queryJSON(member));
|
|
466
|
-
if (memberSegment === 'status:free') {
|
|
467
|
-
segmentedMemberRows = memberRows.filter(member => member.status === 'free');
|
|
468
|
-
memberRows = memberRows.filter(member => member.status !== 'free');
|
|
469
|
-
} else if (memberSegment === 'status:-free') {
|
|
470
|
-
segmentedMemberRows = memberRows.filter(member => member.status !== 'free');
|
|
471
|
-
memberRows = memberRows.filter(member => member.status === 'free');
|
|
472
|
-
} else {
|
|
473
|
-
throw new errors.ValidationError({
|
|
474
|
-
message: tpl(messages.invalidSegment)
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
partitions[memberSegment] = segmentedMemberRows;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if (memberRows.length) {
|
|
482
|
-
partitions.unsegmented = memberRows;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
return partitions;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Detects segment filters in emailModel's html and creates separate batches per segment
|
|
490
|
-
*
|
|
491
|
-
* @param {Object} options
|
|
492
|
-
* @param {Object} options.emailModel - instance of Email model
|
|
493
|
-
* @param {Object} options.options - knex options
|
|
494
|
-
*
|
|
495
|
-
* @returns {Promise<string[]>}
|
|
496
|
-
*/
|
|
497
|
-
async function createSegmentedEmailBatches({emailModel, options}) {
|
|
498
|
-
let memberRows = await getEmailMemberRows({emailModel, options});
|
|
499
|
-
|
|
500
|
-
if (!memberRows.length) {
|
|
501
|
-
return [];
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const segments = getSegmentsFromHtml(emailModel.get('html'));
|
|
505
|
-
const batchIds = [];
|
|
506
|
-
|
|
507
|
-
if (segments.length) {
|
|
508
|
-
const partitionedMembers = partitionMembersBySegment(memberRows, segments);
|
|
509
|
-
|
|
510
|
-
for (const partition in partitionedMembers) {
|
|
511
|
-
const emailBatchIds = await createEmailBatches({
|
|
512
|
-
emailModel,
|
|
513
|
-
memberRows: partitionedMembers[partition],
|
|
514
|
-
memberSegment: partition === 'unsegmented' ? null : partition,
|
|
515
|
-
options
|
|
516
|
-
});
|
|
517
|
-
batchIds.push(...emailBatchIds);
|
|
518
|
-
}
|
|
519
|
-
} else {
|
|
520
|
-
const emailBatchIds = await createEmailBatches({emailModel, memberRows, options});
|
|
521
|
-
batchIds.push(...emailBatchIds);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
return batchIds;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Store email_batch and email_recipient records for an email.
|
|
529
|
-
* Uses knex directly rather than bookshelf to avoid thousands of bookshelf model
|
|
530
|
-
* instantiations and associated processing and event loop blocking.
|
|
531
|
-
*
|
|
532
|
-
* @param {Object} options
|
|
533
|
-
* @param {Object} options.emailModel - instance of Email model
|
|
534
|
-
* @param {string} [options.memberSegment] - NQL filter to apply in addition to the one defined in emailModel
|
|
535
|
-
* @param {Object[]} [options.memberRows] - member rows to be batched
|
|
536
|
-
* @param {Object} options.options - knex options
|
|
537
|
-
* @returns {Promise<string[]>} - created batch ids
|
|
538
|
-
*/
|
|
539
|
-
async function createEmailBatches({emailModel, memberRows, memberSegment, options}) {
|
|
540
|
-
const storeRecipientBatch = async function (recipients) {
|
|
541
|
-
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
|
|
542
|
-
const batchModel = await models.EmailBatch.add({
|
|
543
|
-
email_id: emailModel.id,
|
|
544
|
-
member_segment: memberSegment
|
|
545
|
-
}, knexOptions);
|
|
546
|
-
|
|
547
|
-
const recipientData = [];
|
|
548
|
-
|
|
549
|
-
recipients.forEach((memberRow) => {
|
|
550
|
-
if (!memberRow.id || !memberRow.uuid || !memberRow.email) {
|
|
551
|
-
logging.warn(`Member row not included as email recipient due to missing data - id: ${memberRow.id}, uuid: ${memberRow.uuid}, email: ${memberRow.email}`);
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
recipientData.push({
|
|
556
|
-
id: ObjectID().toHexString(),
|
|
557
|
-
email_id: emailModel.id,
|
|
558
|
-
member_id: memberRow.id,
|
|
559
|
-
batch_id: batchModel.id,
|
|
560
|
-
member_uuid: memberRow.uuid,
|
|
561
|
-
member_email: memberRow.email,
|
|
562
|
-
member_name: memberRow.name
|
|
563
|
-
});
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
const insertQuery = db.knex('email_recipients').insert(recipientData);
|
|
567
|
-
|
|
568
|
-
if (knexOptions.transacting) {
|
|
569
|
-
insertQuery.transacting(knexOptions.transacting);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
await insertQuery;
|
|
573
|
-
|
|
574
|
-
return batchModel.id;
|
|
575
|
-
};
|
|
576
|
-
|
|
577
|
-
debug('createEmailBatches: storing recipient list');
|
|
578
|
-
const startOfRecipientStorage = Date.now();
|
|
579
|
-
let rowsToBatch = memberRows;
|
|
580
|
-
const batches = _.chunk(rowsToBatch, bulkEmailService.BATCH_SIZE);
|
|
581
|
-
const batchIds = await Promise.mapSeries(batches, storeRecipientBatch);
|
|
582
|
-
debug(`createEmailBatches: stored recipient list (${Date.now() - startOfRecipientStorage}ms)`);
|
|
583
|
-
logging.info(`[createEmailBatches] stored recipient list (${Date.now() - startOfRecipientStorage}ms)`);
|
|
584
|
-
|
|
585
|
-
return batchIds;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
const statusChangedHandler = async (emailModel, options) => {
|
|
589
|
-
const emailRetried = emailModel.wasChanged()
|
|
590
|
-
&& emailModel.get('status') === 'pending'
|
|
591
|
-
&& emailModel.previous('status') === 'failed';
|
|
592
|
-
|
|
593
|
-
if (emailRetried) {
|
|
594
|
-
await pendingEmailHandler(emailModel, options);
|
|
595
|
-
}
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
function listen() {
|
|
599
|
-
events.on('email.added', (emailModel, options) => pendingEmailHandler(emailModel, options).catch((e) => {
|
|
600
|
-
logging.error('Error in email.added event handler');
|
|
601
|
-
logging.error(e);
|
|
602
|
-
}));
|
|
603
|
-
events.on('email.edited', (emailModel, options) => statusChangedHandler(emailModel, options).catch((e) => {
|
|
604
|
-
logging.error('Error in email.edited event handler');
|
|
605
|
-
logging.error(e);
|
|
606
|
-
}));
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Public API
|
|
610
|
-
module.exports = {
|
|
611
|
-
listen,
|
|
612
|
-
addEmail,
|
|
613
|
-
retryFailedEmail,
|
|
614
|
-
sendTestEmail,
|
|
615
|
-
// NOTE: below are only exposed for testing purposes
|
|
616
|
-
_transformEmailRecipientFilter: transformEmailRecipientFilter,
|
|
617
|
-
_partitionMembersBySegment: partitionMembersBySegment,
|
|
618
|
-
_getEmailMemberRows: getEmailMemberRows,
|
|
619
|
-
_getFromAddress: getFromAddress,
|
|
620
|
-
_getReplyToAddress: getReplyToAddress,
|
|
621
|
-
_sendEmailJob: sendEmailJob
|
|
622
|
-
};
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* @typedef {'status:free' | 'status:-free'} ValidMemberSegment
|
|
626
|
-
*/
|