ghost 5.22.11 → 5.24.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 (165) hide show
  1. package/.c8rc.e2e.json +21 -0
  2. package/README.md +0 -2
  3. package/components/tryghost-adapter-manager-5.24.0.tgz +0 -0
  4. package/components/tryghost-api-framework-5.24.0.tgz +0 -0
  5. package/components/tryghost-api-version-compatibility-service-5.24.0.tgz +0 -0
  6. package/components/tryghost-audience-feedback-5.24.0.tgz +0 -0
  7. package/components/tryghost-bootstrap-socket-5.24.0.tgz +0 -0
  8. package/components/tryghost-constants-5.24.0.tgz +0 -0
  9. package/components/tryghost-custom-theme-settings-service-5.24.0.tgz +0 -0
  10. package/components/tryghost-data-generator-5.24.0.tgz +0 -0
  11. package/components/tryghost-domain-events-5.24.0.tgz +0 -0
  12. package/components/tryghost-email-analytics-provider-mailgun-5.24.0.tgz +0 -0
  13. package/components/tryghost-email-analytics-service-5.24.0.tgz +0 -0
  14. package/components/tryghost-email-content-generator-5.24.0.tgz +0 -0
  15. package/components/tryghost-email-events-5.24.0.tgz +0 -0
  16. package/components/tryghost-email-service-5.24.0.tgz +0 -0
  17. package/components/tryghost-email-suppression-list-5.24.0.tgz +0 -0
  18. package/components/tryghost-express-dynamic-redirects-5.24.0.tgz +0 -0
  19. package/components/tryghost-extract-api-key-5.24.0.tgz +0 -0
  20. package/components/tryghost-html-to-plaintext-5.24.0.tgz +0 -0
  21. package/components/{tryghost-job-manager-5.22.11.tgz → tryghost-job-manager-5.24.0.tgz} +0 -0
  22. package/components/tryghost-link-redirects-5.24.0.tgz +0 -0
  23. package/components/tryghost-link-replacer-5.24.0.tgz +0 -0
  24. package/components/tryghost-link-tracking-5.24.0.tgz +0 -0
  25. package/components/{tryghost-magic-link-5.22.11.tgz → tryghost-magic-link-5.24.0.tgz} +0 -0
  26. package/components/tryghost-mailgun-client-5.24.0.tgz +0 -0
  27. package/components/{tryghost-member-attribution-5.22.11.tgz → tryghost-member-attribution-5.24.0.tgz} +0 -0
  28. package/components/{tryghost-member-events-5.22.11.tgz → tryghost-member-events-5.24.0.tgz} +0 -0
  29. package/components/tryghost-members-api-5.24.0.tgz +0 -0
  30. package/components/tryghost-members-csv-5.24.0.tgz +0 -0
  31. package/components/tryghost-members-events-service-5.24.0.tgz +0 -0
  32. package/components/tryghost-members-importer-5.24.0.tgz +0 -0
  33. package/components/{tryghost-members-offers-5.22.11.tgz → tryghost-members-offers-5.24.0.tgz} +0 -0
  34. package/components/tryghost-members-payments-5.24.0.tgz +0 -0
  35. package/components/tryghost-members-ssr-5.24.0.tgz +0 -0
  36. package/components/tryghost-members-stripe-service-5.24.0.tgz +0 -0
  37. package/components/tryghost-minifier-5.24.0.tgz +0 -0
  38. package/components/tryghost-mw-api-version-mismatch-5.24.0.tgz +0 -0
  39. package/components/tryghost-mw-cache-control-5.24.0.tgz +0 -0
  40. package/components/tryghost-mw-error-handler-5.24.0.tgz +0 -0
  41. package/components/tryghost-mw-session-from-token-5.24.0.tgz +0 -0
  42. package/components/tryghost-mw-update-user-last-seen-5.24.0.tgz +0 -0
  43. package/components/tryghost-mw-vhost-5.24.0.tgz +0 -0
  44. package/components/tryghost-oembed-service-5.24.0.tgz +0 -0
  45. package/components/tryghost-package-json-5.24.0.tgz +0 -0
  46. package/components/tryghost-referrers-5.24.0.tgz +0 -0
  47. package/components/tryghost-security-5.24.0.tgz +0 -0
  48. package/components/tryghost-session-service-5.24.0.tgz +0 -0
  49. package/components/{tryghost-settings-path-manager-5.22.11.tgz → tryghost-settings-path-manager-5.24.0.tgz} +0 -0
  50. package/components/tryghost-staff-service-5.24.0.tgz +0 -0
  51. package/components/{tryghost-stats-service-5.22.11.tgz → tryghost-stats-service-5.24.0.tgz} +0 -0
  52. package/components/{tryghost-tiers-5.22.11.tgz → tryghost-tiers-5.24.0.tgz} +0 -0
  53. package/components/tryghost-update-check-service-5.24.0.tgz +0 -0
  54. package/components/tryghost-verification-trigger-5.24.0.tgz +0 -0
  55. package/components/tryghost-version-notifications-data-service-5.24.0.tgz +0 -0
  56. package/content/themes/casper/assets/built/screen.css +1 -1
  57. package/content/themes/casper/assets/built/screen.css.map +1 -1
  58. package/content/themes/casper/assets/css/screen.css +3 -5
  59. package/content/themes/casper/default.hbs +2 -2
  60. package/content/themes/casper/package.json +1 -1
  61. package/core/boot.js +5 -1
  62. package/core/built/admin/assets/{chunk.143.8f4f86908026af3b9484.js → chunk.143.dd395a3e804fef2c3b21.js} +14 -14
  63. package/core/built/admin/assets/{chunk.178.0aff330fc5d8e74617b5.js → chunk.178.ec67ba4dc75bcec75c6f.js} +4 -4
  64. package/core/built/admin/assets/chunk.507.71dd4bfc4ccb354cc629.js +267 -0
  65. package/core/built/admin/assets/{chunk.613.695f31829550fb00d43c.js → chunk.613.6bbcc18224567657fc2e.js} +3089 -3074
  66. package/core/built/admin/assets/{chunk.613.695f31829550fb00d43c.js.LICENSE.txt → chunk.613.6bbcc18224567657fc2e.js.LICENSE.txt} +0 -0
  67. package/core/built/admin/assets/{ghost-b204dcc6ad523053868da9b2d8d65f80.js → ghost-34bc21923675def87aa2516f72ca15d7.js} +1287 -1248
  68. package/core/built/admin/assets/ghost-dark-a2076b08f23a9e6340072bc7b06ec9e7.css +1 -0
  69. package/core/built/admin/assets/ghost-f428683b68c0eea9042acc7c021641e0.css +1 -0
  70. package/core/built/admin/assets/{vendor-dc9f883b3468ff84794cf13741e6c4b4.js → vendor-04415b2b8a59aa9567dfa5d819ada71c.js} +315 -303
  71. package/core/built/admin/index.html +6 -6
  72. package/core/cli/record-test.js +47 -0
  73. package/core/frontend/apps/amp/lib/helpers/amp_content.js +5 -1
  74. package/core/frontend/apps/amp/lib/views/amp.hbs +10 -0
  75. package/core/frontend/helpers/ghost_head.js +1 -7
  76. package/core/server/api/endpoints/db.js +17 -11
  77. package/core/server/api/endpoints/email-previews.js +10 -2
  78. package/core/server/api/endpoints/emails.js +20 -14
  79. package/core/server/api/endpoints/posts.js +4 -1
  80. package/core/server/api/endpoints/utils/serializers/input/posts.js +4 -11
  81. package/core/server/api/endpoints/utils/serializers/output/db.js +3 -7
  82. package/core/server/api/endpoints/utils/serializers/output/members.js +5 -0
  83. package/core/server/data/importer/email-template.js +163 -0
  84. package/core/server/data/importer/import-manager.js +116 -35
  85. package/core/server/data/importer/importers/data/base.js +1 -0
  86. package/core/server/data/importer/importers/data/data-importer.js +27 -1
  87. package/core/server/data/migrations/versions/5.24/2022-11-21-09-32-add-source-columns-to-emails-table.js +17 -0
  88. package/core/server/data/migrations/versions/5.24/2022-11-21-15-03-populate-source-column-with-html-for-emails.js +19 -0
  89. package/core/server/data/migrations/versions/5.24/2022-11-21-15-57-add-error-columns-for-email-batches.js +22 -0
  90. package/core/server/data/schema/default-settings/default-settings.json +1 -1
  91. package/core/server/data/schema/schema.js +11 -0
  92. package/core/server/models/base/plugins/bulk-operations.js +0 -1
  93. package/core/server/models/comment.js +1 -1
  94. package/core/server/models/email.js +2 -1
  95. package/core/server/models/member-newsletter.js +9 -0
  96. package/core/server/models/member.js +1 -9
  97. package/core/server/models/stripe-customer-subscription.js +3 -7
  98. package/core/server/services/bulk-email/bulk-email-processor.js +14 -23
  99. package/core/server/services/email-service/index.js +3 -0
  100. package/core/server/services/email-service/wrapper.js +64 -0
  101. package/core/server/services/email-suppression-list/index.js +1 -0
  102. package/core/server/services/email-suppression-list/service.js +38 -0
  103. package/core/server/services/mega/mega.js +65 -6
  104. package/core/server/services/mega/segment-parser.js +1 -3
  105. package/core/server/services/members/api.js +4 -2
  106. package/core/server/services/members/middleware.js +19 -3
  107. package/core/server/services/members/service.js +36 -29
  108. package/core/server/services/members/utils.js +7 -0
  109. package/core/server/services/posts/posts-service.js +19 -6
  110. package/core/server/web/members/app.js +12 -10
  111. package/core/shared/config/defaults.json +1 -1
  112. package/core/shared/labs.js +3 -7
  113. package/core/shared/sentry.js +25 -3
  114. package/ghost.js +1 -0
  115. package/package.json +111 -106
  116. package/playwright.config.js +26 -0
  117. package/yarn.lock +263 -358
  118. package/components/tryghost-adapter-manager-5.22.11.tgz +0 -0
  119. package/components/tryghost-api-framework-5.22.11.tgz +0 -0
  120. package/components/tryghost-api-version-compatibility-service-5.22.11.tgz +0 -0
  121. package/components/tryghost-audience-feedback-5.22.11.tgz +0 -0
  122. package/components/tryghost-bootstrap-socket-5.22.11.tgz +0 -0
  123. package/components/tryghost-constants-5.22.11.tgz +0 -0
  124. package/components/tryghost-custom-theme-settings-service-5.22.11.tgz +0 -0
  125. package/components/tryghost-data-generator-5.22.11.tgz +0 -0
  126. package/components/tryghost-domain-events-5.22.11.tgz +0 -0
  127. package/components/tryghost-email-analytics-provider-mailgun-5.22.11.tgz +0 -0
  128. package/components/tryghost-email-analytics-service-5.22.11.tgz +0 -0
  129. package/components/tryghost-email-content-generator-5.22.11.tgz +0 -0
  130. package/components/tryghost-express-dynamic-redirects-5.22.11.tgz +0 -0
  131. package/components/tryghost-extract-api-key-5.22.11.tgz +0 -0
  132. package/components/tryghost-html-to-plaintext-5.22.11.tgz +0 -0
  133. package/components/tryghost-link-redirects-5.22.11.tgz +0 -0
  134. package/components/tryghost-link-replacer-5.22.11.tgz +0 -0
  135. package/components/tryghost-link-tracking-5.22.11.tgz +0 -0
  136. package/components/tryghost-mailgun-client-5.22.11.tgz +0 -0
  137. package/components/tryghost-member-analytics-service-5.22.11.tgz +0 -0
  138. package/components/tryghost-members-analytics-ingress-5.22.11.tgz +0 -0
  139. package/components/tryghost-members-api-5.22.11.tgz +0 -0
  140. package/components/tryghost-members-csv-5.22.11.tgz +0 -0
  141. package/components/tryghost-members-events-service-5.22.11.tgz +0 -0
  142. package/components/tryghost-members-importer-5.22.11.tgz +0 -0
  143. package/components/tryghost-members-payments-5.22.11.tgz +0 -0
  144. package/components/tryghost-members-ssr-5.22.11.tgz +0 -0
  145. package/components/tryghost-members-stripe-service-5.22.11.tgz +0 -0
  146. package/components/tryghost-minifier-5.22.11.tgz +0 -0
  147. package/components/tryghost-mw-api-version-mismatch-5.22.11.tgz +0 -0
  148. package/components/tryghost-mw-cache-control-5.22.11.tgz +0 -0
  149. package/components/tryghost-mw-error-handler-5.22.11.tgz +0 -0
  150. package/components/tryghost-mw-session-from-token-5.22.11.tgz +0 -0
  151. package/components/tryghost-mw-update-user-last-seen-5.22.11.tgz +0 -0
  152. package/components/tryghost-mw-vhost-5.22.11.tgz +0 -0
  153. package/components/tryghost-oembed-service-5.22.11.tgz +0 -0
  154. package/components/tryghost-package-json-5.22.11.tgz +0 -0
  155. package/components/tryghost-referrers-5.22.11.tgz +0 -0
  156. package/components/tryghost-security-5.22.11.tgz +0 -0
  157. package/components/tryghost-session-service-5.22.11.tgz +0 -0
  158. package/components/tryghost-staff-service-5.22.11.tgz +0 -0
  159. package/components/tryghost-update-check-service-5.22.11.tgz +0 -0
  160. package/components/tryghost-verification-trigger-5.22.11.tgz +0 -0
  161. package/components/tryghost-version-notifications-data-service-5.22.11.tgz +0 -0
  162. package/core/built/admin/assets/chunk.174.3a133d51d9b45097c101.js +0 -245
  163. package/core/built/admin/assets/ghost-03c7a25d23ad4d0725da171f8d7c7b2a.css +0 -1
  164. package/core/built/admin/assets/ghost-dark-8896a076fc06ec2b09343b1c9df7feca.css +0 -1
  165. package/core/server/models/member-analytic-event.js +0 -9
@@ -15,6 +15,8 @@ const db = require('../../data/db');
15
15
  const models = require('../../models');
16
16
  const postEmailSerializer = require('./post-email-serializer');
17
17
  const {getSegmentsFromHtml} = require('./segment-parser');
18
+ const emailSuppressionList = require('../email-suppression-list');
19
+ const labs = require('../../../shared/labs');
18
20
 
19
21
  // 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
20
22
  const events = require('../../lib/common/events');
@@ -236,6 +238,8 @@ const addEmail = async (postModel, options) => {
236
238
  from: emailData.from,
237
239
  reply_to: emailData.replyTo,
238
240
  html: emailData.html,
241
+ source: emailData.html,
242
+ source_type: 'html',
239
243
  plaintext: emailData.plaintext,
240
244
  submitted_at: moment().toDate(),
241
245
  track_opens: !!settingsCache.get('email_track_opens'),
@@ -265,6 +269,10 @@ const retryFailedEmail = async (emailModel) => {
265
269
  };
266
270
 
267
271
  async function pendingEmailHandler(emailModel, options) {
272
+ if (labs.isSet('emailStability')) {
273
+ return;
274
+ }
275
+
268
276
  // CASE: do not send email if we import a database
269
277
  // TODO: refactor post.published events to never fire on importing
270
278
  if (options && options.importing) {
@@ -283,13 +291,14 @@ async function pendingEmailHandler(emailModel, options) {
283
291
  if (!process.env.NODE_ENV.startsWith('test')) {
284
292
  return jobsService.addJob({
285
293
  job: sendEmailJob,
286
- data: {emailModel},
294
+ data: {emailId: emailModel.id},
287
295
  offloaded: false
288
296
  });
289
297
  }
290
298
  }
291
299
 
292
- async function sendEmailJob({emailModel, options}) {
300
+ async function sendEmailJob({emailId, options}) {
301
+ logging.info('[sendEmailJob] Started for ' + emailId);
293
302
  let startEmailSend = null;
294
303
 
295
304
  try {
@@ -304,10 +313,45 @@ async function sendEmailJob({emailModel, options}) {
304
313
  await limitService.errorIfWouldGoOverLimit('emails');
305
314
  }
306
315
 
316
+ // Check if the email is still pending. And set the status to submitting in one transaction.
317
+ let hasSingleAccess = false;
318
+ let emailModel;
319
+ await models.Base.transaction(async (transacting) => {
320
+ const knexOptions = {...options, transacting, forUpdate: true};
321
+ emailModel = await models.Email.findOne({id: emailId}, knexOptions);
322
+
323
+ if (!emailModel) {
324
+ throw new errors.IncorrectUsageError({
325
+ message: 'Provided email id does not match a known email record',
326
+ context: {
327
+ id: emailId
328
+ }
329
+ });
330
+ }
331
+
332
+ if (emailModel.get('status') !== 'pending') {
333
+ // We don't throw this, because we don't want to mark this email as failed
334
+ logging.error(new errors.IncorrectUsageError({
335
+ message: 'Emails can only be processed when in the "pending" state',
336
+ context: `Email "${emailId}" has state "${emailModel.get('status')}"`,
337
+ code: 'EMAIL_NOT_PENDING'
338
+ }));
339
+ return;
340
+ }
341
+
342
+ await emailModel.save({status: 'submitting'}, Object.assign({}, knexOptions, {patch: true}));
343
+ hasSingleAccess = true;
344
+ });
345
+
346
+ if (!hasSingleAccess || !emailModel) {
347
+ return;
348
+ }
349
+
307
350
  // Create email batch and recipient rows unless this is a retry and they already exist
308
351
  const existingBatchCount = await emailModel.related('emailBatches').count('id');
309
352
 
310
353
  if (existingBatchCount === 0) {
354
+ logging.info('[sendEmailJob] Creating new batches for ' + emailId);
311
355
  let newBatchCount = 0;
312
356
 
313
357
  await models.Base.transaction(async (transacting) => {
@@ -316,15 +360,23 @@ async function sendEmailJob({emailModel, options}) {
316
360
  });
317
361
 
318
362
  if (newBatchCount === 0) {
363
+ logging.info('[sendEmailJob] No batches created for ' + emailId);
364
+ await emailModel.save({status: 'submitted'}, {patch: true});
319
365
  return;
320
366
  }
321
367
  }
322
368
 
323
369
  debug('sendEmailJob: sending email');
324
370
  startEmailSend = Date.now();
325
- await bulkEmailService.processEmail({emailId: emailModel.get('id'), options});
371
+ await bulkEmailService.processEmail({emailModel, options});
326
372
  debug(`sendEmailJob: sent email (${Date.now() - startEmailSend}ms)`);
327
373
  } catch (error) {
374
+ if (startEmailSend) {
375
+ logging.info(`[sendEmailJob] Failed sending ${emailId} (${Date.now() - startEmailSend}ms)`);
376
+ } else {
377
+ logging.info(`[sendEmailJob] Failed sending ${emailId}`);
378
+ }
379
+
328
380
  if (startEmailSend) {
329
381
  debug(`sendEmailJob: send email failed (${Date.now() - startEmailSend}ms)`);
330
382
  }
@@ -334,10 +386,10 @@ async function sendEmailJob({emailModel, options}) {
334
386
  errorMessage = errorMessage.substring(0, 2000);
335
387
  }
336
388
 
337
- await emailModel.save({
389
+ await models.Email.edit({
338
390
  status: 'failed',
339
391
  error: errorMessage
340
- }, {patch: true});
392
+ }, {id: emailId});
341
393
 
342
394
  throw new errors.InternalServerError({
343
395
  err: error,
@@ -511,9 +563,16 @@ async function createEmailBatches({emailModel, memberRows, memberSegment, option
511
563
 
512
564
  debug('createEmailBatches: storing recipient list');
513
565
  const startOfRecipientStorage = Date.now();
514
- const batches = _.chunk(memberRows, bulkEmailService.BATCH_SIZE);
566
+ const emails = memberRows.map(row => row.email);
567
+ const emailSuppressionData = await emailSuppressionList.getBulkSuppressionData(emails);
568
+ const emailSuppressedLookup = _.zipObject(emails, emailSuppressionData);
569
+ const filteredRows = memberRows.filter((row) => {
570
+ return emailSuppressedLookup[row.email].suppressed === false;
571
+ });
572
+ const batches = _.chunk(filteredRows, bulkEmailService.BATCH_SIZE);
515
573
  const batchIds = await Promise.mapSeries(batches, storeRecipientBatch);
516
574
  debug(`createEmailBatches: stored recipient list (${Date.now() - startOfRecipientStorage}ms)`);
575
+ logging.info(`[createEmailBatches] stored recipient list (${Date.now() - startOfRecipientStorage}ms)`);
517
576
 
518
577
  return batchIds;
519
578
  }
@@ -1,5 +1,3 @@
1
- const labs = require('../../../shared/labs');
2
-
3
1
  const getSegmentsFromHtml = (html) => {
4
2
  const cheerio = require('cheerio');
5
3
  const $ = cheerio.load(html);
@@ -11,7 +9,7 @@ const getSegmentsFromHtml = (html) => {
11
9
  /**
12
10
  * Always add free and paid segments if email has paywall card
13
11
  */
14
- if (labs.isSet('newsletterPaywall') && html.indexOf('<!--members-only-->') !== -1) {
12
+ if (html.indexOf('<!--members-only-->') !== -1) {
15
13
  allSegments = allSegments.concat(['status:free', 'status:-free']);
16
14
  }
17
15
 
@@ -16,6 +16,7 @@ const offersService = require('../offers');
16
16
  const tiersService = require('../tiers');
17
17
  const newslettersService = require('../newsletters');
18
18
  const memberAttributionService = require('../member-attribution');
19
+ const emailSuppressionList = require('../email-suppression-list');
19
20
 
20
21
  const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
21
22
 
@@ -177,6 +178,7 @@ function createApiInstance(config) {
177
178
  StripeCustomer: models.MemberStripeCustomer,
178
179
  StripeCustomerSubscription: models.StripeCustomerSubscription,
179
180
  Member: models.Member,
181
+ MemberNewsletter: models.MemberNewsletter,
180
182
  MemberCancelEvent: models.MemberCancelEvent,
181
183
  MemberSubscribeEvent: models.MemberSubscribeEvent,
182
184
  MemberPaidSubscriptionEvent: models.MemberPaidSubscriptionEvent,
@@ -185,7 +187,6 @@ function createApiInstance(config) {
185
187
  MemberPaymentEvent: models.MemberPaymentEvent,
186
188
  MemberStatusEvent: models.MemberStatusEvent,
187
189
  MemberProductEvent: models.MemberProductEvent,
188
- MemberAnalyticEvent: models.MemberAnalyticEvent,
189
190
  MemberCreatedEvent: models.MemberCreatedEvent,
190
191
  SubscriptionCreatedEvent: models.SubscriptionCreatedEvent,
191
192
  MemberLinkClickEvent: models.MemberClickEvent,
@@ -203,7 +204,8 @@ function createApiInstance(config) {
203
204
  offersAPI: offersService.api,
204
205
  labsService: labsService,
205
206
  newslettersService: newslettersService,
206
- memberAttributionService: memberAttributionService.service
207
+ memberAttributionService: memberAttributionService.service,
208
+ emailSuppressionList
207
209
  });
208
210
 
209
211
  return membersApiInstance;
@@ -1,6 +1,7 @@
1
1
  const _ = require('lodash');
2
2
  const logging = require('@tryghost/logging');
3
3
  const membersService = require('./service');
4
+ const emailSuppressionList = require('../email-suppression-list');
4
5
  const models = require('../../models');
5
6
  const urlUtils = require('../../../shared/url-utils');
6
7
  const spamPrevention = require('../../web/shared/middleware/api/spam-prevention');
@@ -29,7 +30,7 @@ const loadMemberSession = async function (req, res, next) {
29
30
 
30
31
  /**
31
32
  * Require member authentication, and make it possible to authenticate via uuid.
32
- * You can chain this after loadMemberSession to make it possible to authetnicate via both the uuid and the session.
33
+ * You can chain this after loadMemberSession to make it possible to authenticate via both the uuid and the session.
33
34
  */
34
35
  const authMemberByUuid = async function (req, res, next) {
35
36
  try {
@@ -39,7 +40,7 @@ const authMemberByUuid = async function (req, res, next) {
39
40
  // Already authenticated via session
40
41
  return next();
41
42
  }
42
-
43
+
43
44
  throw new errors.UnauthorizedError({
44
45
  messsage: tpl(messages.missingUuid)
45
46
  });
@@ -97,6 +98,20 @@ const getMemberData = async function (req, res) {
97
98
  }
98
99
  };
99
100
 
101
+ const deleteSuppression = async function (req, res) {
102
+ try {
103
+ const member = await membersService.ssr.getMemberDataFromSession(req, res);
104
+ await emailSuppressionList.removeEmail(member.email);
105
+ res.writeHead(204);
106
+ res.end();
107
+ } catch (err) {
108
+ res.writeHead(err.statusCode, {
109
+ 'Content-Type': 'text/plain;charset=UTF-8'
110
+ });
111
+ res.end(err.message);
112
+ }
113
+ };
114
+
100
115
  const getMemberNewsletters = async function (req, res) {
101
116
  try {
102
117
  const memberUuid = req.query.uuid;
@@ -262,5 +277,6 @@ module.exports = {
262
277
  getMemberData,
263
278
  updateMemberData,
264
279
  updateMemberNewsletters,
265
- deleteSession
280
+ deleteSession,
281
+ deleteSuppression
266
282
  };
@@ -56,7 +56,7 @@ const membersImporter = new MembersCSVImporter({
56
56
  return tiersService.api.readDefaultTier();
57
57
  },
58
58
  sendEmail: ghostMailer.send.bind(ghostMailer),
59
- isSet: labsService.isSet.bind(labsService),
59
+ isSet: flag => labsService.isSet(flag),
60
60
  addJob: jobsService.addJob.bind(jobsService),
61
61
  knex: db.knex,
62
62
  urlFor: urlUtils.urlFor.bind(urlUtils),
@@ -74,6 +74,36 @@ const processImport = async (options) => {
74
74
  return result;
75
75
  };
76
76
 
77
+ const updateVerificationTrigger = () => {
78
+ verificationTrigger = new VerificationTrigger({
79
+ apiTriggerThreshold: _.get(config.get('hostSettings'), 'emailVerification.apiThreshold'),
80
+ adminTriggerThreshold: _.get(config.get('hostSettings'), 'emailVerification.adminThreshold'),
81
+ importTriggerThreshold: _.get(config.get('hostSettings'), 'emailVerification.importThreshold'),
82
+ isVerified: () => config.get('hostSettings:emailVerification:verified') === true,
83
+ isVerificationRequired: () => settingsCache.get('email_verification_required') === true,
84
+ sendVerificationEmail: async ({subject, message, amountTriggered}) => {
85
+ const escalationAddress = config.get('hostSettings:emailVerification:escalationAddress');
86
+ const fromAddress = config.get('user_email');
87
+
88
+ if (escalationAddress) {
89
+ await ghostMailer.send({
90
+ subject,
91
+ html: tpl(message, {
92
+ amountTriggered: amountTriggered,
93
+ siteUrl: urlUtils.getSiteUrl()
94
+ }),
95
+ forceTextContent: true,
96
+ from: fromAddress,
97
+ to: escalationAddress
98
+ });
99
+ }
100
+ },
101
+ membersStats,
102
+ Settings: models.Settings,
103
+ eventRepository: membersApi.events
104
+ });
105
+ };
106
+
77
107
  module.exports = {
78
108
  async init() {
79
109
  const stripeService = require('../stripe');
@@ -110,33 +140,7 @@ module.exports = {
110
140
  getMembersApi: () => module.exports.api
111
141
  });
112
142
 
113
- verificationTrigger = new VerificationTrigger({
114
- apiTriggerThreshold: _.get(config.get('hostSettings'), 'emailVerification.apiThreshold'),
115
- adminTriggerThreshold: _.get(config.get('hostSettings'), 'emailVerification.adminThreshold'),
116
- importTriggerThreshold: _.get(config.get('hostSettings'), 'emailVerification.importThreshold'),
117
- isVerified: () => config.get('hostSettings:emailVerification:verified') === true,
118
- isVerificationRequired: () => settingsCache.get('email_verification_required') === true,
119
- sendVerificationEmail: ({subject, message, amountTriggered}) => {
120
- const escalationAddress = config.get('hostSettings:emailVerification:escalationAddress');
121
- const fromAddress = config.get('user_email');
122
-
123
- if (escalationAddress) {
124
- ghostMailer.send({
125
- subject,
126
- html: tpl(message, {
127
- amountTriggered: amountTriggered,
128
- siteUrl: urlUtils.getSiteUrl()
129
- }),
130
- forceTextContent: true,
131
- from: fromAddress,
132
- to: escalationAddress
133
- });
134
- }
135
- },
136
- membersStats,
137
- Settings: models.Settings,
138
- eventRepository: membersApi.events
139
- });
143
+ updateVerificationTrigger();
140
144
 
141
145
  (async () => {
142
146
  try {
@@ -185,7 +189,10 @@ module.exports = {
185
189
  processImport: processImport,
186
190
 
187
191
  stats: membersStats,
188
- export: require('./exporter/query')
192
+ export: require('./exporter/query'),
193
+
194
+ // Only for tests
195
+ _updateVerificationTrigger: updateVerificationTrigger
189
196
  };
190
197
 
191
198
  module.exports.middleware = require('./middleware');
@@ -1,3 +1,5 @@
1
+ const labsService = require('../../../shared/labs');
2
+
1
3
  function formatNewsletterResponse(newsletters) {
2
4
  return newsletters.map(({id, name, description, sort_order: sortOrder}) => {
3
5
  return {id, name, description, sort_order: sortOrder};
@@ -23,5 +25,10 @@ module.exports.formattedMemberResponse = function formattedMemberResponse(member
23
25
  if (member.newsletters) {
24
26
  data.newsletters = formatNewsletterResponse(member.newsletters);
25
27
  }
28
+
29
+ if (labsService.isSet('suppressionList') && member.email_suppression) {
30
+ data.email_suppression = member.email_suppression;
31
+ }
32
+
26
33
  return data;
27
34
  };
@@ -8,12 +8,13 @@ const messages = {
8
8
  };
9
9
 
10
10
  class PostsService {
11
- constructor({mega, urlUtils, models, isSet, stats}) {
11
+ constructor({mega, urlUtils, models, isSet, stats, emailService}) {
12
12
  this.mega = mega;
13
13
  this.urlUtils = urlUtils;
14
14
  this.models = models;
15
15
  this.isSet = isSet;
16
16
  this.stats = stats;
17
+ this.emailService = emailService;
17
18
  }
18
19
 
19
20
  async editPost(frame) {
@@ -41,12 +42,22 @@ class PostsService {
41
42
 
42
43
  if (sendEmail) {
43
44
  let postEmail = model.relations.email;
45
+ let email;
44
46
 
45
47
  if (!postEmail) {
46
- const email = await this.mega.addEmail(model, frame.options);
47
- model.set('email', email);
48
+ if (this.isSet('emailStability')) {
49
+ email = await this.emailService.createEmail(model);
50
+ } else {
51
+ email = await this.mega.addEmail(model, frame.options);
52
+ }
48
53
  } else if (postEmail && postEmail.get('status') === 'failed') {
49
- const email = await this.mega.retryFailedEmail(postEmail);
54
+ if (this.isSet('emailStability')) {
55
+ email = await this.emailService.retryEmail(postEmail);
56
+ } else {
57
+ email = await this.mega.retryFailedEmail(postEmail);
58
+ }
59
+ }
60
+ if (email) {
50
61
  model.set('email', email);
51
62
  }
52
63
  }
@@ -123,6 +134,7 @@ const getPostServiceInstance = () => {
123
134
  const labs = require('../../../shared/labs');
124
135
  const models = require('../../models');
125
136
  const PostStats = require('./stats/post-stats');
137
+ const emailService = require('../email-service');
126
138
 
127
139
  const postStats = new PostStats();
128
140
 
@@ -130,8 +142,9 @@ const getPostServiceInstance = () => {
130
142
  mega: mega,
131
143
  urlUtils: urlUtils,
132
144
  models: models,
133
- isSet: labs.isSet.bind(labs),
134
- stats: postStats
145
+ isSet: flag => labs.isSet(flag), // don't use bind, that breaks test subbing of labs
146
+ stats: postStats,
147
+ emailService: emailService.service
135
148
  });
136
149
  };
137
150
 
@@ -45,35 +45,37 @@ module.exports = function setupMembersApp() {
45
45
  membersApp.put('/api/member', bodyParser.json({limit: '50mb'}), middleware.updateMemberData);
46
46
  membersApp.post('/api/member/email', bodyParser.json({limit: '50mb'}), (req, res) => membersService.api.middleware.updateEmailAddress(req, res));
47
47
 
48
+ // Remove email from suppression list
49
+ membersApp.delete('/api/member/suppression', labs.enabledMiddleware('suppressionList'), middleware.deleteSuppression);
50
+
48
51
  // Manage session
49
52
  membersApp.get('/api/session', middleware.getIdentityToken);
50
53
  membersApp.delete('/api/session', middleware.deleteSession);
51
54
 
52
55
  // NOTE: this is wrapped in a function to ensure we always go via the getter
53
56
  membersApp.post(
54
- '/api/send-magic-link',
55
- bodyParser.json(),
57
+ '/api/send-magic-link',
58
+ bodyParser.json(),
56
59
  // Prevent brute forcing email addresses (user enumeration)
57
- shared.middleware.brute.membersAuthEnumeration,
60
+ shared.middleware.brute.membersAuthEnumeration,
58
61
  // Prevent brute forcing passwords for the same email address
59
- shared.middleware.brute.membersAuth,
62
+ shared.middleware.brute.membersAuth,
60
63
  (req, res, next) => membersService.api.middleware.sendMagicLink(req, res, next)
61
64
  );
62
65
  membersApp.post('/api/create-stripe-checkout-session', (req, res, next) => membersService.api.middleware.createCheckoutSession(req, res, next));
63
66
  membersApp.post('/api/create-stripe-update-session', (req, res, next) => membersService.api.middleware.createCheckoutSetupSession(req, res, next));
64
67
  membersApp.put('/api/subscriptions/:id', (req, res, next) => membersService.api.middleware.updateSubscription(req, res, next));
65
- membersApp.post('/api/events', labs.enabledMiddleware('membersActivity'), middleware.loadMemberSession, (req, res, next) => membersService.api.middleware.createEvents(req, res, next));
66
68
 
67
69
  // Comments
68
70
  membersApp.use('/api/comments', commentRouter());
69
71
 
70
72
  // Feedback
71
73
  membersApp.post(
72
- '/api/feedback',
73
- labs.enabledMiddleware('audienceFeedback'),
74
- bodyParser.json({limit: '50mb'}),
75
- middleware.loadMemberSession,
76
- middleware.authMemberByUuid,
74
+ '/api/feedback',
75
+ labs.enabledMiddleware('audienceFeedback'),
76
+ bodyParser.json({limit: '50mb'}),
77
+ middleware.loadMemberSession,
78
+ middleware.authMemberByUuid,
77
79
  http(api.feedbackMembers.add)
78
80
  );
79
81
 
@@ -161,7 +161,7 @@
161
161
  },
162
162
  "portal": {
163
163
  "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
164
- "version": "2.19"
164
+ "version": "2.20"
165
165
  },
166
166
  "sodoSearch": {
167
167
  "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
@@ -15,12 +15,6 @@ const messages = {
15
15
 
16
16
  // flags in this list always return `true`, allows quick global enable prior to full flag removal
17
17
  const GA_FEATURES = [
18
- 'newsletterPaywall',
19
- 'freeTrial',
20
- 'compExpiring',
21
- 'searchHelper',
22
- 'emailAlerts',
23
- 'fixNewsletterLinks',
24
18
  'sourceAttribution',
25
19
  'memberAttribution',
26
20
  'audienceFeedback'
@@ -35,7 +29,9 @@ const BETA_FEATURES = [
35
29
  const ALPHA_FEATURES = [
36
30
  'urlCache',
37
31
  'beforeAfterCard',
38
- 'lexicalEditor'
32
+ 'lexicalEditor',
33
+ 'suppressionList',
34
+ 'emailStability'
39
35
  ];
40
36
 
41
37
  module.exports.GA_KEYS = [...GA_FEATURES];
@@ -4,12 +4,32 @@ const errors = require('@tryghost/errors');
4
4
 
5
5
  if (sentryConfig && !sentryConfig.disabled) {
6
6
  const Sentry = require('@sentry/node');
7
- const version = require('../../package.json').version;
7
+ const version = require('@tryghost/version').full;
8
8
  const environment = config.get('env');
9
9
  Sentry.init({
10
10
  dsn: sentryConfig.dsn,
11
11
  release: 'ghost@' + version,
12
- environment: environment
12
+ environment: environment,
13
+ beforeSend: function (event, hint) {
14
+ const exception = hint.originalException;
15
+
16
+ event.tags = event.tags || {};
17
+
18
+ if (errors.utils.isGhostError(exception)) {
19
+ // Unexpected errors have a generic error message, set it back to context if there is one
20
+ if (exception.code === 'UNEXPECTED_ERROR' && exception.context !== null) {
21
+ event.exception.values[0].type = exception.context;
22
+ }
23
+
24
+ // This is a Ghost Error, copy all our extra data to tags
25
+ event.tags.type = exception.errorType;
26
+ event.tags.code = exception.code;
27
+ event.tags.id = exception.id;
28
+ event.tags.statusCode = exception.statusCode;
29
+ }
30
+
31
+ return event;
32
+ }
13
33
  });
14
34
 
15
35
  module.exports = {
@@ -34,9 +54,11 @@ if (sentryConfig && !sentryConfig.disabled) {
34
54
  next();
35
55
  };
36
56
 
57
+ const noop = () => {};
58
+
37
59
  module.exports = {
38
60
  requestHandler: expressNoop,
39
61
  errorHandler: expressNoop,
40
- captureException: () => {}
62
+ captureException: noop
41
63
  };
42
64
  }
package/ghost.js CHANGED
@@ -19,6 +19,7 @@ switch (mode) {
19
19
  case 'repl':
20
20
  case 'timetravel':
21
21
  case 'generate-data':
22
+ case 'record-test':
22
23
  command.run(mode);
23
24
  break;
24
25
  default: