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.
Files changed (154) hide show
  1. package/components/{tryghost-adapter-cache-memory-ttl-5.37.0.tgz → tryghost-adapter-cache-memory-ttl-5.38.0.tgz} +0 -0
  2. package/components/tryghost-adapter-cache-redis-5.38.0.tgz +0 -0
  3. package/components/{tryghost-adapter-manager-5.37.0.tgz → tryghost-adapter-manager-5.38.0.tgz} +0 -0
  4. package/components/{tryghost-api-framework-5.37.0.tgz → tryghost-api-framework-5.38.0.tgz} +0 -0
  5. package/components/{tryghost-api-version-compatibility-service-5.37.0.tgz → tryghost-api-version-compatibility-service-5.38.0.tgz} +0 -0
  6. package/components/tryghost-audience-feedback-5.38.0.tgz +0 -0
  7. package/components/tryghost-bootstrap-socket-5.38.0.tgz +0 -0
  8. package/components/{tryghost-constants-5.37.0.tgz → tryghost-constants-5.38.0.tgz} +0 -0
  9. package/components/tryghost-custom-theme-settings-service-5.38.0.tgz +0 -0
  10. package/components/{tryghost-data-generator-5.37.0.tgz → tryghost-data-generator-5.38.0.tgz} +0 -0
  11. package/components/tryghost-domain-events-5.38.0.tgz +0 -0
  12. package/components/tryghost-dynamic-routing-events-5.38.0.tgz +0 -0
  13. package/components/tryghost-email-analytics-provider-mailgun-5.38.0.tgz +0 -0
  14. package/components/{tryghost-email-analytics-service-5.37.0.tgz → tryghost-email-analytics-service-5.38.0.tgz} +0 -0
  15. package/components/tryghost-email-content-generator-5.38.0.tgz +0 -0
  16. package/components/tryghost-email-events-5.38.0.tgz +0 -0
  17. package/components/tryghost-email-service-5.38.0.tgz +0 -0
  18. package/components/{tryghost-email-suppression-list-5.37.0.tgz → tryghost-email-suppression-list-5.38.0.tgz} +0 -0
  19. package/components/tryghost-event-aware-cache-wrapper-5.38.0.tgz +0 -0
  20. package/components/{tryghost-express-dynamic-redirects-5.37.0.tgz → tryghost-express-dynamic-redirects-5.38.0.tgz} +0 -0
  21. package/components/tryghost-external-media-inliner-5.38.0.tgz +0 -0
  22. package/components/tryghost-extract-api-key-5.38.0.tgz +0 -0
  23. package/components/tryghost-html-to-plaintext-5.38.0.tgz +0 -0
  24. package/components/tryghost-i18n-5.38.0.tgz +0 -0
  25. package/components/{tryghost-importer-handler-content-files-5.37.0.tgz → tryghost-importer-handler-content-files-5.38.0.tgz} +0 -0
  26. package/components/tryghost-importer-revue-5.38.0.tgz +0 -0
  27. package/components/tryghost-job-manager-5.38.0.tgz +0 -0
  28. package/components/tryghost-link-redirects-5.38.0.tgz +0 -0
  29. package/components/tryghost-link-replacer-5.38.0.tgz +0 -0
  30. package/components/{tryghost-link-tracking-5.37.0.tgz → tryghost-link-tracking-5.38.0.tgz} +0 -0
  31. package/components/{tryghost-magic-link-5.37.0.tgz → tryghost-magic-link-5.38.0.tgz} +0 -0
  32. package/components/tryghost-mailgun-client-5.38.0.tgz +0 -0
  33. package/components/tryghost-member-attribution-5.38.0.tgz +0 -0
  34. package/components/tryghost-member-events-5.38.0.tgz +0 -0
  35. package/components/tryghost-members-api-5.38.0.tgz +0 -0
  36. package/components/tryghost-members-csv-5.38.0.tgz +0 -0
  37. package/components/{tryghost-members-events-service-5.37.0.tgz → tryghost-members-events-service-5.38.0.tgz} +0 -0
  38. package/components/{tryghost-members-importer-5.37.0.tgz → tryghost-members-importer-5.38.0.tgz} +0 -0
  39. package/components/tryghost-members-offers-5.38.0.tgz +0 -0
  40. package/components/tryghost-members-payments-5.38.0.tgz +0 -0
  41. package/components/tryghost-members-ssr-5.38.0.tgz +0 -0
  42. package/components/{tryghost-members-stripe-service-5.37.0.tgz → tryghost-members-stripe-service-5.38.0.tgz} +0 -0
  43. package/components/tryghost-milestones-5.38.0.tgz +0 -0
  44. package/components/tryghost-minifier-5.38.0.tgz +0 -0
  45. package/components/tryghost-mw-api-version-mismatch-5.38.0.tgz +0 -0
  46. package/components/{tryghost-mw-cache-control-5.37.0.tgz → tryghost-mw-cache-control-5.38.0.tgz} +0 -0
  47. package/components/tryghost-mw-error-handler-5.38.0.tgz +0 -0
  48. package/components/tryghost-mw-session-from-token-5.38.0.tgz +0 -0
  49. package/components/tryghost-mw-update-user-last-seen-5.38.0.tgz +0 -0
  50. package/components/tryghost-mw-version-match-5.38.0.tgz +0 -0
  51. package/components/tryghost-mw-vhost-5.38.0.tgz +0 -0
  52. package/components/tryghost-oembed-service-5.38.0.tgz +0 -0
  53. package/components/tryghost-package-json-5.38.0.tgz +0 -0
  54. package/components/{tryghost-referrers-5.37.0.tgz → tryghost-referrers-5.38.0.tgz} +0 -0
  55. package/components/tryghost-security-5.38.0.tgz +0 -0
  56. package/components/tryghost-session-service-5.38.0.tgz +0 -0
  57. package/components/tryghost-settings-path-manager-5.38.0.tgz +0 -0
  58. package/components/tryghost-slack-notifications-5.38.0.tgz +0 -0
  59. package/components/tryghost-staff-service-5.38.0.tgz +0 -0
  60. package/components/tryghost-stats-service-5.38.0.tgz +0 -0
  61. package/components/tryghost-tiers-5.38.0.tgz +0 -0
  62. package/components/{tryghost-update-check-service-5.37.0.tgz → tryghost-update-check-service-5.38.0.tgz} +0 -0
  63. package/components/tryghost-verification-trigger-5.38.0.tgz +0 -0
  64. package/components/{tryghost-version-notifications-data-service-5.37.0.tgz → tryghost-version-notifications-data-service-5.38.0.tgz} +0 -0
  65. package/components/tryghost-webmentions-5.38.0.tgz +0 -0
  66. package/core/boot.js +11 -4
  67. package/core/built/admin/assets/{chunk.143.27cd10a38f877e715b35.js → chunk.143.c6802c882a911797ce4f.js} +6 -6
  68. package/core/built/admin/assets/{chunk.178.dd6cf17fb0986acf19d6.js → chunk.178.09faefd4027fcba4113d.js} +4 -4
  69. package/core/built/admin/assets/{chunk.652.bb618bc5abf23bed4e87.js → chunk.220.9ca2950240aba3fced21.js} +1836 -1774
  70. package/core/built/admin/assets/{chunk.79.4a959c324df25480b90e.js → chunk.79.acb7dd01e1c785f4920c.js} +12 -11
  71. package/core/built/admin/assets/{ghost-2948791640be026b987b88f89034bc85.js → ghost-35103ff053c43f1dfa7f35821c3c2412.js} +29 -29
  72. package/core/built/admin/assets/{ghost-efbe4dcc249d119a955b038aae5c980d.css → ghost-a9307c9cfe26a4bc621e02cd3bae421a.css} +1 -1
  73. package/core/built/admin/assets/{ghost-dark-6ea4b338f17a43c204b7c1e207b90cd7.css → ghost-dark-f309cf445255344e4861a95ecb8f1920.css} +1 -1
  74. package/core/built/admin/assets/vendor-b982e3bf1020bff77b2a3c44d5f59e55.js +269 -269
  75. package/core/built/admin/index.html +5 -5
  76. package/core/frontend/helpers/ghost_head.js +4 -1
  77. package/core/frontend/services/routing/StaticPagesRouter.js +1 -1
  78. package/core/frontend/services/sitemap/base-generator.js +5 -1
  79. package/core/server/adapters/storage/LocalImagesStorage.js +1 -1
  80. package/core/server/api/endpoints/email-previews.js +2 -43
  81. package/core/server/api/endpoints/emails.js +1 -22
  82. package/core/server/api/endpoints/utils/serializers/output/mappers/emails.js +14 -8
  83. package/core/server/data/importer/import-manager.js +8 -1
  84. package/core/server/data/migrations/versions/4.9/05-fix-missed-mobiledoc-url-transforms.js +1 -1
  85. package/core/server/lib/common/events.js +16 -23
  86. package/core/server/models/base/plugins/relations.js +5 -3
  87. package/core/server/models/index.js +5 -0
  88. package/core/server/services/comments/emails.js +2 -2
  89. package/core/server/services/email-service/wrapper.js +2 -0
  90. package/core/server/services/link-tracking/LinkClickRepository.js +1 -1
  91. package/core/server/services/media-inliner/service.js +49 -3
  92. package/core/server/services/mentions/service.js +6 -1
  93. package/core/server/services/posts/posts-service.js +3 -14
  94. package/core/server/services/staff/index.js +2 -0
  95. package/core/server/services/url/Urls.js +10 -2
  96. package/core/shared/labs.js +0 -1
  97. package/package.json +138 -138
  98. package/yarn.lock +267 -259
  99. package/components/tryghost-adapter-cache-redis-5.37.0.tgz +0 -0
  100. package/components/tryghost-audience-feedback-5.37.0.tgz +0 -0
  101. package/components/tryghost-bootstrap-socket-5.37.0.tgz +0 -0
  102. package/components/tryghost-custom-theme-settings-service-5.37.0.tgz +0 -0
  103. package/components/tryghost-domain-events-5.37.0.tgz +0 -0
  104. package/components/tryghost-dynamic-routing-events-5.37.0.tgz +0 -0
  105. package/components/tryghost-email-analytics-provider-mailgun-5.37.0.tgz +0 -0
  106. package/components/tryghost-email-content-generator-5.37.0.tgz +0 -0
  107. package/components/tryghost-email-events-5.37.0.tgz +0 -0
  108. package/components/tryghost-email-service-5.37.0.tgz +0 -0
  109. package/components/tryghost-event-aware-cache-wrapper-5.37.0.tgz +0 -0
  110. package/components/tryghost-external-media-inliner-5.37.0.tgz +0 -0
  111. package/components/tryghost-extract-api-key-5.37.0.tgz +0 -0
  112. package/components/tryghost-html-to-plaintext-5.37.0.tgz +0 -0
  113. package/components/tryghost-i18n-5.37.0.tgz +0 -0
  114. package/components/tryghost-importer-revue-5.37.0.tgz +0 -0
  115. package/components/tryghost-job-manager-5.37.0.tgz +0 -0
  116. package/components/tryghost-link-redirects-5.37.0.tgz +0 -0
  117. package/components/tryghost-link-replacer-5.37.0.tgz +0 -0
  118. package/components/tryghost-mailgun-client-5.37.0.tgz +0 -0
  119. package/components/tryghost-member-attribution-5.37.0.tgz +0 -0
  120. package/components/tryghost-member-events-5.37.0.tgz +0 -0
  121. package/components/tryghost-members-api-5.37.0.tgz +0 -0
  122. package/components/tryghost-members-csv-5.37.0.tgz +0 -0
  123. package/components/tryghost-members-offers-5.37.0.tgz +0 -0
  124. package/components/tryghost-members-payments-5.37.0.tgz +0 -0
  125. package/components/tryghost-members-ssr-5.37.0.tgz +0 -0
  126. package/components/tryghost-milestones-5.37.0.tgz +0 -0
  127. package/components/tryghost-minifier-5.37.0.tgz +0 -0
  128. package/components/tryghost-mw-api-version-mismatch-5.37.0.tgz +0 -0
  129. package/components/tryghost-mw-error-handler-5.37.0.tgz +0 -0
  130. package/components/tryghost-mw-session-from-token-5.37.0.tgz +0 -0
  131. package/components/tryghost-mw-update-user-last-seen-5.37.0.tgz +0 -0
  132. package/components/tryghost-mw-version-match-5.37.0.tgz +0 -0
  133. package/components/tryghost-mw-vhost-5.37.0.tgz +0 -0
  134. package/components/tryghost-oembed-service-5.37.0.tgz +0 -0
  135. package/components/tryghost-package-json-5.37.0.tgz +0 -0
  136. package/components/tryghost-security-5.37.0.tgz +0 -0
  137. package/components/tryghost-session-service-5.37.0.tgz +0 -0
  138. package/components/tryghost-settings-path-manager-5.37.0.tgz +0 -0
  139. package/components/tryghost-slack-notifications-5.37.0.tgz +0 -0
  140. package/components/tryghost-staff-service-5.37.0.tgz +0 -0
  141. package/components/tryghost-stats-service-5.37.0.tgz +0 -0
  142. package/components/tryghost-tiers-5.37.0.tgz +0 -0
  143. package/components/tryghost-verification-trigger-5.37.0.tgz +0 -0
  144. package/components/tryghost-webmentions-5.37.0.tgz +0 -0
  145. package/core/server/services/bulk-email/bulk-email-processor.js +0 -289
  146. package/core/server/services/bulk-email/index.js +0 -1
  147. package/core/server/services/mega/email-preview.js +0 -54
  148. package/core/server/services/mega/feedback-buttons.js +0 -66
  149. package/core/server/services/mega/index.js +0 -14
  150. package/core/server/services/mega/mega.js +0 -626
  151. package/core/server/services/mega/post-email-serializer.js +0 -559
  152. package/core/server/services/mega/segment-parser.js +0 -20
  153. package/core/server/services/mega/template.js +0 -1319
  154. /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
- */