ghost 4.43.1 → 4.46.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 (120) hide show
  1. package/Gruntfile.js +1 -1
  2. package/core/boot.js +2 -0
  3. package/core/built/assets/{chunk.3.6e2ed2d00856e12bd81a.js → chunk.3.52b444495dfcf50afb0b.js} +20 -20
  4. package/core/built/assets/ghost-dark-155e039c0d991b7af75dea8cd3846b11.css +1 -0
  5. package/core/built/assets/{ghost.min-2a278873d60d6a13a4c05a396e5bed5e.js → ghost.min-30e597cb65b62b31a9422ca9c0eb2890.js} +845 -670
  6. package/core/built/assets/ghost.min-bd8cd0185fd5dfc8291502f801e443e6.css +1 -0
  7. package/core/built/assets/icons/clock.svg +1 -1
  8. package/core/built/assets/icons/email-at.svg +1 -0
  9. package/core/built/assets/icons/email-body.svg +1 -0
  10. package/core/built/assets/icons/email-footer.svg +1 -0
  11. package/core/built/assets/icons/email-header.svg +1 -0
  12. package/core/built/assets/icons/email-member.svg +1 -0
  13. package/core/built/assets/icons/email-name.svg +1 -0
  14. package/core/built/assets/icons/member.svg +1 -3
  15. package/core/built/assets/icons/send-email.svg +1 -1
  16. package/core/built/assets/img/abstract-2-2937e2902b64360d0cbe4cec8bd8479b.jpg +0 -0
  17. package/core/built/assets/img/abstract-c52b2f4208e7fd2e7b8abd8b1eec4f7b.jpg +0 -0
  18. package/core/built/assets/img/community-background-3f501ff1d764d0cb81f7c2cbacfc6503.jpg +0 -0
  19. package/core/built/assets/img/community-be8c1dcecfb157f2bfba5cababc8e686.jpg +0 -0
  20. package/core/built/assets/img/newsletter-1-197ae8063dfb2e22278d355198029c9e.jpg +0 -0
  21. package/core/built/assets/img/newsletter-2-5a2c7693ea9380d4282061302c01267a.jpg +0 -0
  22. package/core/built/assets/img/resource-1-722f202795856e4a5596c8a3b7bedc43.jpg +0 -0
  23. package/core/built/assets/{vendor.min-21f79c68a284acb1b70039f3f63e5507.js → vendor.min-97fd438f4772c5ec6bb30ad779b8530e.js} +868 -523
  24. package/core/frontend/apps/amp/lib/helpers/amp_content.js +2 -3
  25. package/core/frontend/apps/amp/lib/views/amp.hbs +5 -3
  26. package/core/frontend/helpers/get.js +1 -1
  27. package/core/frontend/services/routing/controllers/unsubscribe.js +22 -0
  28. package/core/frontend/web/middleware/cors.js +56 -0
  29. package/core/frontend/web/middleware/index.js +1 -0
  30. package/core/frontend/web/middleware/static-theme.js +8 -8
  31. package/core/frontend/web/site.js +1 -48
  32. package/core/server/api/canary/authentication.js +2 -2
  33. package/core/server/api/canary/members.js +3 -0
  34. package/core/server/api/canary/newsletters.js +86 -4
  35. package/core/server/api/canary/posts.js +1 -0
  36. package/core/server/api/canary/stats.js +11 -2
  37. package/core/server/api/canary/utils/serializers/input/members.js +22 -0
  38. package/core/server/api/canary/utils/serializers/output/mappers/pages.js +1 -0
  39. package/core/server/api/canary/utils/serializers/output/mappers/posts.js +2 -0
  40. package/core/server/api/canary/utils/serializers/output/members.js +13 -2
  41. package/core/server/api/shared/http.js +1 -1
  42. package/core/server/api/v2/utils/serializers/output/utils/mapper.js +2 -0
  43. package/core/server/api/v3/utils/serializers/output/utils/mapper.js +3 -0
  44. package/core/server/data/importer/importers/data/settings.js +0 -3
  45. package/core/server/data/migrations/utils.js +40 -0
  46. package/core/server/data/migrations/versions/4.43/2022-03-28-19-26-recreate-newsletter-table.js +5 -5
  47. package/core/server/data/migrations/versions/4.44/2022-04-06-15-22-populate-type-column-for-paid-subscription-events.js +21 -0
  48. package/core/server/data/migrations/versions/4.44/2022-04-08-11-54-add-cancelled-events.js +51 -0
  49. package/core/server/data/migrations/versions/4.44/2022-04-11-08-24-add-newsletter-permissions.js +33 -0
  50. package/core/server/data/migrations/versions/4.44/2022-04-11-10-54-add-mrr-to-subscriptions.js +8 -0
  51. package/core/server/data/migrations/versions/4.44/2022-04-12-07-33-fill-mrr.js +29 -0
  52. package/core/server/data/migrations/versions/4.44/2022-04-13-12-00-remove-newsletter-sender-name-not-null-constraint.js +33 -0
  53. package/core/server/data/migrations/versions/4.44/2022-04-15-07-53-add-offer-id-to-subscriptions.js +9 -0
  54. package/core/server/data/migrations/versions/4.45/2022-04-19-12-23-backfill-subscriptions-offers.js +60 -0
  55. package/core/server/data/migrations/versions/4.45/2022-04-20-11-25-add-newsletter-read-permission.js +9 -0
  56. package/core/server/data/migrations/versions/4.45/2022-04-21-02-55-add-notifications-key-entry-to-settings-table.js +8 -0
  57. package/core/server/data/migrations/versions/4.46/2022-04-13-12-00-add-created-at-newsletters.js +6 -0
  58. package/core/server/data/migrations/versions/4.46/2022-04-13-12-01-add-updated-at-newsletters.js +6 -0
  59. package/core/server/data/migrations/versions/4.46/2022-04-13-12-02-fill-created-at-newsletters.js +19 -0
  60. package/core/server/data/migrations/versions/4.46/2022-04-13-12-03-drop-nullable-created-at-newsletters.js +3 -0
  61. package/core/server/data/migrations/versions/4.46/2022-04-13-12-08-newsletters-show-header-name.js +7 -0
  62. package/core/server/data/migrations/versions/4.46/2022-04-13-12-57-add-uuid-column-to-newsletters.js +8 -0
  63. package/core/server/data/migrations/versions/4.46/2022-04-13-12-58-fill-uuid-for-newsletters.js +19 -0
  64. package/core/server/data/migrations/versions/4.46/2022-04-13-12-59-drop-nullable-uuid-newsletters.js +3 -0
  65. package/core/server/data/migrations/versions/4.46/2022-04-13-13-00-add-default-newsletter.js +85 -0
  66. package/core/server/data/migrations/versions/4.46/2022-04-20-08-39-map-subscribers-to-default-newsletter.js +66 -0
  67. package/core/server/data/migrations/versions/4.46/2022-04-22-07-43-add-newsletter-id-to-subscribe-events.js +9 -0
  68. package/core/server/data/migrations/versions/4.46/2022-04-27-07-59-set-newsletter-id-subscribe-events.js +31 -0
  69. package/core/server/data/schema/commands.js +14 -0
  70. package/core/server/data/schema/default-settings/default-settings.json +4 -0
  71. package/core/server/data/schema/fixtures/fixtures.json +32 -1
  72. package/core/server/data/schema/schema.js +15 -8
  73. package/core/server/models/base/plugins/generate-slug.js +2 -2
  74. package/core/server/models/email.js +4 -0
  75. package/core/server/models/label.js +1 -1
  76. package/core/server/models/member-subscribe-event.js +4 -0
  77. package/core/server/models/member.js +29 -0
  78. package/core/server/models/newsletter.js +101 -11
  79. package/core/server/models/post.js +15 -5
  80. package/core/server/models/role.js +1 -1
  81. package/core/server/models/stripe-customer-subscription.js +4 -0
  82. package/core/server/models/tag.js +1 -1
  83. package/core/server/models/user.js +1 -1
  84. package/core/server/services/api-version-compatibility/index.js +29 -0
  85. package/core/server/services/auth/members/index.js +1 -1
  86. package/core/server/services/auth/setup.js +17 -7
  87. package/core/server/services/mega/email-preview.js +4 -1
  88. package/core/server/services/mega/mega.js +86 -27
  89. package/core/server/services/mega/post-email-serializer.js +17 -14
  90. package/core/server/services/mega/template.js +24 -3
  91. package/core/server/services/members/api.js +2 -2
  92. package/core/server/services/members/middleware.js +69 -2
  93. package/core/server/services/members/service.js +7 -12
  94. package/core/server/services/newsletters/emails/verify-email.js +166 -0
  95. package/core/server/services/newsletters/index.js +14 -7
  96. package/core/server/services/newsletters/service.js +237 -6
  97. package/core/server/services/posts/posts-service.js +18 -1
  98. package/core/server/services/stats/service.js +2 -6
  99. package/core/server/services/users.js +20 -20
  100. package/core/server/web/admin/views/default-prod.html +4 -4
  101. package/core/server/web/admin/views/default.html +4 -4
  102. package/core/server/web/api/app.js +3 -0
  103. package/core/server/web/api/canary/admin/app.js +3 -0
  104. package/core/server/web/api/canary/admin/routes.js +3 -0
  105. package/core/server/web/api/canary/content/app.js +3 -0
  106. package/core/server/web/api/middleware/cors.js +1 -1
  107. package/core/server/web/api/v2/admin/app.js +3 -0
  108. package/core/server/web/api/v2/content/app.js +3 -0
  109. package/core/server/web/api/v3/admin/app.js +3 -0
  110. package/core/server/web/api/v3/content/app.js +3 -0
  111. package/core/server/web/members/app.js +5 -0
  112. package/core/shared/config/defaults.json +2 -2
  113. package/core/shared/labs.js +4 -2
  114. package/core/shared/settings-cache/public.js +1 -1
  115. package/package.json +82 -78
  116. package/yarn.lock +1062 -679
  117. package/core/built/assets/ghost-dark-1933079797e24ccb8839657020830be5.css +0 -1
  118. package/core/built/assets/ghost.min-38f3c38c0c6a1864f57079b068a0b0ce.css +0 -1
  119. package/core/server/services/stats/lib/members-stats-service.js +0 -161
  120. package/core/server/services/stats/lib/mrr-stats-service.js +0 -154
@@ -15,7 +15,9 @@ const jobsService = require('../jobs');
15
15
  const db = require('../../data/db');
16
16
  const models = require('../../models');
17
17
  const postEmailSerializer = require('./post-email-serializer');
18
+ const labs = require('../../../shared/labs');
18
19
  const {getSegmentsFromHtml} = require('./segment-parser');
20
+ const labsService = require('../../../shared/labs');
19
21
 
20
22
  // 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
23
  const events = require('../../lib/common/events');
@@ -25,27 +27,22 @@ const messages = {
25
27
  unexpectedFilterError: 'Unexpected {property} value "{value}", expected an NQL equivalent',
26
28
  noneFilterError: 'Cannot send email to "none" {property}',
27
29
  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.'
30
+ sendEmailRequestFailed: 'The email service was unable to send an email batch.',
31
+ newsletterVisibilityError: 'Unexpected visibility value "{value}". Use one of the valid: "members", "paid".'
29
32
  };
30
33
 
31
- const getFromAddress = () => {
32
- let fromAddress = membersService.config.getEmailFromAddress();
33
-
34
+ const getFromAddress = (senderName, fromAddress) => {
34
35
  if (/@localhost$/.test(fromAddress) || /@ghost.local$/.test(fromAddress)) {
35
36
  const localAddress = 'localhost@example.com';
36
37
  logging.warn(`Rewriting bulk email from address ${fromAddress} to ${localAddress}`);
37
38
  fromAddress = localAddress;
38
39
  }
39
40
 
40
- const siteTitle = settingsCache.get('title') ? settingsCache.get('title').replace(/"/g, '\\"') : '';
41
-
42
- return siteTitle ? `"${siteTitle}"<${fromAddress}>` : fromAddress;
41
+ return senderName ? `"${senderName}"<${fromAddress}>` : fromAddress;
43
42
  };
44
43
 
45
- const getReplyToAddress = () => {
46
- const fromAddress = membersService.config.getEmailFromAddress();
44
+ const getReplyToAddress = (fromAddress, replyAddressOption) => {
47
45
  const supportAddress = membersService.config.getEmailSupportAddress();
48
- const replyAddressOption = settingsCache.get('members_reply_address');
49
46
 
50
47
  return (replyAddressOption === 'support') ? supportAddress : fromAddress;
51
48
  };
@@ -57,14 +54,28 @@ const getReplyToAddress = () => {
57
54
  * @param {ValidAPIVersion} options.apiVersion - api version to be used when serializing email data
58
55
  */
59
56
  const getEmailData = async (postModel, options) => {
60
- const {subject, html, plaintext} = await postEmailSerializer.serialize(postModel, options);
57
+ let newsletter = await postModel.related('newsletter').fetch();
58
+ if (!newsletter) {
59
+ newsletter = await models.Newsletter.getDefaultNewsletter();
60
+ }
61
+ const {subject, html, plaintext} = await postEmailSerializer.serialize(postModel, newsletter, options);
62
+
63
+ let senderName = settingsCache.get('title') ? settingsCache.get('title').replace(/"/g, '\\"') : '';
64
+ if (newsletter.get('sender_name')) {
65
+ senderName = newsletter.get('sender_name');
66
+ }
67
+
68
+ let fromAddress = membersService.config.getEmailFromAddress();
69
+ if (newsletter.get('sender_email')) {
70
+ fromAddress = newsletter.get('sender_email');
71
+ }
61
72
 
62
73
  return {
63
74
  subject,
64
75
  html,
65
76
  plaintext,
66
- from: getFromAddress(),
67
- replyTo: getReplyToAddress()
77
+ from: getFromAddress(senderName, fromAddress),
78
+ replyTo: getReplyToAddress(fromAddress, newsletter.get('sender_reply_to'))
68
79
  };
69
80
  };
70
81
 
@@ -126,7 +137,15 @@ const sendTestEmail = async (postModel, toEmails, apiVersion, memberSegment) =>
126
137
  * @param {string} emailRecipientFilter NQL filter for members
127
138
  * @param {object} options
128
139
  */
129
- const transformEmailRecipientFilter = (emailRecipientFilter, {errorProperty = 'email_recipient_filter'} = {}) => {
140
+ const transformEmailRecipientFilter = (emailRecipientFilter, {errorProperty = 'email_recipient_filter'} = {}, newsletter = null) => {
141
+ let filter = [];
142
+
143
+ if (!newsletter) {
144
+ filter.push(`subscribed:true`);
145
+ } else {
146
+ filter.push(`newsletters.id:${newsletter.id}`);
147
+ }
148
+
130
149
  switch (emailRecipientFilter) {
131
150
  // `paid` and `free` were swapped out for NQL filters in 4.5.0, we shouldn't see them here now
132
151
  case 'paid':
@@ -138,7 +157,7 @@ const transformEmailRecipientFilter = (emailRecipientFilter, {errorProperty = 'e
138
157
  })
139
158
  });
140
159
  case 'all':
141
- return 'subscribed:true';
160
+ break;
142
161
  case 'none':
143
162
  throw new errors.InternalServerError({
144
163
  message: tpl(messages.noneFilterError, {
@@ -146,8 +165,29 @@ const transformEmailRecipientFilter = (emailRecipientFilter, {errorProperty = 'e
146
165
  })
147
166
  });
148
167
  default:
149
- return `subscribed:true+(${emailRecipientFilter})`;
168
+ filter.push(`(${emailRecipientFilter})`);
169
+ break;
170
+ }
171
+
172
+ if (newsletter) {
173
+ const visibility = newsletter.get('visibility');
174
+ switch (visibility) {
175
+ case 'members':
176
+ // No need to add a member status filter as the email is available to all members
177
+ break;
178
+ case 'paid':
179
+ filter.push(`status:-free`);
180
+ break;
181
+ default:
182
+ throw new errors.InternalServerError({
183
+ message: tpl(messages.newsletterVisibilityError, {
184
+ value: visibility
185
+ })
186
+ });
187
+ }
150
188
  }
189
+
190
+ return filter.join('+');
151
191
  };
152
192
 
153
193
  /**
@@ -159,6 +199,7 @@ const transformEmailRecipientFilter = (emailRecipientFilter, {errorProperty = 'e
159
199
  * @param {object} postModel Post Model Object
160
200
  * @param {object} options
161
201
  * @param {ValidAPIVersion} options.apiVersion - api version to be used when serializing email data
202
+ * @param {string} options.newsletter_id - the newsletter_id to send the email to
162
203
  */
163
204
 
164
205
  const addEmail = async (postModel, options) => {
@@ -175,12 +216,16 @@ const addEmail = async (postModel, options) => {
175
216
  const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
176
217
  const filterOptions = Object.assign({}, knexOptions, {limit: 1});
177
218
 
219
+ let newsletter;
220
+ if (labsService.isSet('multipleNewsletters')) {
221
+ newsletter = await postModel.related('newsletter').fetch(Object.assign({}, {require: false}, _.pick(options, ['transacting'])));
222
+ }
178
223
  const emailRecipientFilter = postModel.get('email_recipient_filter');
179
- filterOptions.filter = transformEmailRecipientFilter(emailRecipientFilter, {errorProperty: 'email_recipient_filter'});
224
+ filterOptions.filter = transformEmailRecipientFilter(emailRecipientFilter, {errorProperty: 'email_recipient_filter'}, newsletter);
180
225
 
181
226
  const startRetrieve = Date.now();
182
227
  debug('addEmail: retrieving members count');
183
- const {meta: {pagination: {total: membersCount}}} = await membersService.api.members.list(Object.assign({}, knexOptions, filterOptions));
228
+ const {meta: {pagination: {total: membersCount}}} = await membersService.api.members.list({...knexOptions, ...filterOptions});
184
229
  debug(`addEmail: retrieved members count - ${membersCount} members (${Date.now() - startRetrieve}ms)`);
185
230
 
186
231
  // NOTE: don't create email object when there's nobody to send the email to
@@ -211,7 +256,8 @@ const addEmail = async (postModel, options) => {
211
256
  plaintext: emailData.plaintext,
212
257
  submitted_at: moment().toDate(),
213
258
  track_opens: !!settingsCache.get('email_track_opens'),
214
- recipient_filter: emailRecipientFilter
259
+ recipient_filter: emailRecipientFilter,
260
+ newsletter_id: options.newsletter_id
215
261
  }, knexOptions);
216
262
  } else {
217
263
  return existing;
@@ -271,7 +317,11 @@ async function handleUnsubscribeRequest(req) {
271
317
  }
272
318
 
273
319
  try {
274
- const memberModel = await membersService.api.members.update({subscribed: false}, {id: member.id});
320
+ let memberData = {subscribed: false};
321
+ if (labs.isSet('multipleNewsletters')) {
322
+ memberData.newsletters = [];
323
+ }
324
+ const memberModel = await membersService.api.members.update(memberData, {id: member.id});
275
325
  return memberModel.toJSON();
276
326
  } catch (err) {
277
327
  throw new errors.InternalServerError({
@@ -296,11 +346,14 @@ async function pendingEmailHandler(emailModel, options) {
296
346
  const emailAnalyticsJobs = require('../email-analytics/jobs');
297
347
  emailAnalyticsJobs.scheduleRecurringJobs();
298
348
 
299
- return jobsService.addJob({
300
- job: sendEmailJob,
301
- data: {emailModel},
302
- offloaded: false
303
- });
349
+ // @TODO move this into the jobService
350
+ if (!process.env.NODE_ENV.startsWith('test')) {
351
+ return jobsService.addJob({
352
+ job: sendEmailJob,
353
+ data: {emailModel},
354
+ offloaded: false
355
+ });
356
+ }
304
357
  }
305
358
 
306
359
  async function sendEmailJob({emailModel, options}) {
@@ -375,7 +428,11 @@ async function getEmailMemberRows({emailModel, memberSegment, options}) {
375
428
  const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
376
429
  const filterOptions = Object.assign({}, knexOptions);
377
430
 
378
- const recipientFilter = transformEmailRecipientFilter(emailModel.get('recipient_filter'), {errorProperty: 'recipient_filter'});
431
+ let newsletter = null;
432
+ if (labsService.isSet('multipleNewsletters')) {
433
+ newsletter = await emailModel.related('newsletter').fetch(Object.assign({}, {require: false}, _.pick(options, ['transacting'])));
434
+ }
435
+ const recipientFilter = transformEmailRecipientFilter(emailModel.get('recipient_filter'), {errorProperty: 'recipient_filter'}, newsletter);
379
436
  filterOptions.filter = recipientFilter;
380
437
 
381
438
  if (memberSegment) {
@@ -552,7 +609,9 @@ module.exports = {
552
609
  // NOTE: below are only exposed for testing purposes
553
610
  _transformEmailRecipientFilter: transformEmailRecipientFilter,
554
611
  _partitionMembersBySegment: partitionMembersBySegment,
555
- _getEmailMemberRows: getEmailMemberRows
612
+ _getEmailMemberRows: getEmailMemberRows,
613
+ _getFromAddress: getFromAddress,
614
+ _getReplyToAddress: getReplyToAddress
556
615
  };
557
616
 
558
617
  /**
@@ -169,21 +169,22 @@ const parseReplacements = (email) => {
169
169
  return replacements;
170
170
  };
171
171
 
172
- const getTemplateSettings = async () => {
172
+ const getTemplateSettings = async (newsletter) => {
173
173
  const accentColor = settingsCache.get('accent_color');
174
174
  const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
175
175
  const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
176
176
 
177
177
  const templateSettings = {
178
- headerImage: settingsCache.get('newsletter_header_image'),
179
- showHeaderIcon: settingsCache.get('newsletter_show_header_icon') && settingsCache.get('icon'),
180
- showHeaderTitle: settingsCache.get('newsletter_show_header_title'),
181
- showFeatureImage: settingsCache.get('newsletter_show_feature_image'),
182
- titleFontCategory: settingsCache.get('newsletter_title_font_category'),
183
- titleAlignment: settingsCache.get('newsletter_title_alignment'),
184
- bodyFontCategory: settingsCache.get('newsletter_body_font_category'),
185
- showBadge: settingsCache.get('newsletter_show_badge'),
186
- footerContent: settingsCache.get('newsletter_footer_content'),
178
+ headerImage: newsletter.get('header_image'),
179
+ showHeaderIcon: newsletter.get('show_header_icon') && settingsCache.get('icon'),
180
+ showHeaderTitle: newsletter.get('show_header_title'),
181
+ showFeatureImage: newsletter.get('show_feature_image'),
182
+ titleFontCategory: newsletter.get('title_font_category'),
183
+ titleAlignment: newsletter.get('title_alignment'),
184
+ bodyFontCategory: newsletter.get('body_font_category'),
185
+ showBadge: newsletter.get('show_badge'),
186
+ footerContent: newsletter.get('footer_content'),
187
+ showHeaderName: newsletter.get('show_header_name'),
187
188
  accentColor,
188
189
  adjustedAccentColor,
189
190
  adjustedAccentContrastColor
@@ -221,7 +222,7 @@ const getTemplateSettings = async () => {
221
222
  return templateSettings;
222
223
  };
223
224
 
224
- const serialize = async (postModel, options = {isBrowserPreview: false, apiVersion: 'v4'}) => {
225
+ const serialize = async (postModel, newsletter, options = {isBrowserPreview: false, apiVersion: 'v4'}) => {
225
226
  const post = await serializePostModel(postModel, options.apiVersion);
226
227
 
227
228
  const timezone = settingsCache.get('timezone');
@@ -290,11 +291,11 @@ const serialize = async (postModel, options = {isBrowserPreview: false, apiVersi
290
291
  }
291
292
  }
292
293
 
293
- const templateSettings = await getTemplateSettings();
294
+ const templateSettings = await getTemplateSettings(newsletter);
294
295
 
295
296
  const render = template;
296
297
 
297
- let htmlTemplate = render({post, site: getSite(), templateSettings});
298
+ let htmlTemplate = render({post, site: getSite(), templateSettings, newsletter: newsletter.toJSON()});
298
299
 
299
300
  if (options.isBrowserPreview) {
300
301
  const previewUnsubscribeUrl = createUnsubscribeUrl(null);
@@ -339,5 +340,7 @@ module.exports = {
339
340
  serialize,
340
341
  createUnsubscribeUrl,
341
342
  renderEmailForSegment,
342
- parseReplacements
343
+ parseReplacements,
344
+ // Export for tests
345
+ _getTemplateSettings: getTemplateSettings
343
346
  };
@@ -1,6 +1,6 @@
1
1
  /* eslint indent: warn, no-irregular-whitespace: warn */
2
2
  const iff = (cond, yes, no) => (cond ? yes : no);
3
- module.exports = ({post, site, templateSettings}) => {
3
+ module.exports = ({post, site, newsletter, templateSettings}) => {
4
4
  const date = new Date();
5
5
  const hasFeatureImageCaption = templateSettings.showFeatureImage && post.feature_image && post.feature_image_caption;
6
6
  return `<!doctype html>
@@ -322,6 +322,9 @@ figure blockquote p {
322
322
  font-weight: 700;
323
323
  text-transform: uppercase;
324
324
  text-align: center;
325
+ }
326
+
327
+ .site-url-bottom-padding {
325
328
  padding-bottom: 50px;
326
329
  }
327
330
 
@@ -329,6 +332,13 @@ figure blockquote p {
329
332
  color: #15212A;
330
333
  }
331
334
 
335
+ .site-subtitle {
336
+ color: #8695a4;
337
+ font-size: 14px;
338
+ font-weight: 400;
339
+ text-transform: none;
340
+ }
341
+
332
342
  .post-title {
333
343
  padding-bottom: 10px;
334
344
  font-size: 42px;
@@ -1158,7 +1168,7 @@ ${ templateSettings.showBadge ? `
1158
1168
  ` : ''}
1159
1169
 
1160
1170
 
1161
- ${ templateSettings.showHeaderIcon || templateSettings.showHeaderTitle ? `
1171
+ ${ templateSettings.showHeaderIcon || templateSettings.showHeaderTitle || templateSettings.showHeaderName ? `
1162
1172
  <tr>
1163
1173
  <td class="${templateSettings.showHeaderTitle ? `site-info-bordered` : `site-info`}" width="100%" align="center">
1164
1174
  <table role="presentation" border="0" cellpadding="0" cellspacing="0">
@@ -1169,9 +1179,20 @@ ${ templateSettings.showBadge ? `
1169
1179
  ` : ``}
1170
1180
  ${ templateSettings.showHeaderTitle ? `
1171
1181
  <tr>
1172
- <td class="site-url"><div style="width: 100% !important;"><a href="${site.url}" class="site-title">${site.title}</a></div></td>
1182
+ <td class="site-url ${!templateSettings.showHeaderName ? 'site-url-bottom-padding' : ''}"><div style="width: 100% !important;"><a href="${site.url}" class="site-title">${site.title}</a></div></td>
1173
1183
  </tr>
1174
1184
  ` : ``}
1185
+ ${ templateSettings.showHeaderName && templateSettings.showHeaderTitle ? `
1186
+ <tr>
1187
+ <td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="${site.url}" class="site-subtitle">${newsletter.name}</a></div></td>
1188
+ </tr>
1189
+ ` : ``}
1190
+ ${ templateSettings.showHeaderName && !templateSettings.showHeaderTitle ? `
1191
+ <tr>
1192
+ <td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="${site.url}" class="site-title">${newsletter.name}</a></div></td>
1193
+ </tr>
1194
+ ` : ``}
1195
+
1175
1196
  </table>
1176
1197
  </td>
1177
1198
  </tr>
@@ -13,7 +13,7 @@ const SingleUseTokenProvider = require('./SingleUseTokenProvider');
13
13
  const urlUtils = require('../../../shared/url-utils');
14
14
  const labsService = require('../../../shared/labs');
15
15
  const offersService = require('../offers');
16
- const getNewslettersServiceInstance = require('../newsletters');
16
+ const newslettersService = require('../newsletters');
17
17
 
18
18
  const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
19
19
 
@@ -197,7 +197,7 @@ function createApiInstance(config) {
197
197
  stripeAPIService: stripeService.api,
198
198
  offersAPI: offersService.api,
199
199
  labsService: labsService,
200
- newslettersService: getNewslettersServiceInstance({NewsletterModel: models.Newsletter})
200
+ newslettersService: newslettersService
201
201
  });
202
202
 
203
203
  return membersApiInstance;
@@ -9,6 +9,7 @@ const settingsCache = require('../../../shared/settings-cache');
9
9
  const {formattedMemberResponse} = require('./utils');
10
10
  const labsService = require('../../../shared/labs');
11
11
  const config = require('../../../shared/config');
12
+ const newslettersService = require('../newsletters');
12
13
 
13
14
  // @TODO: This piece of middleware actually belongs to the frontend, not to the member app
14
15
  // Need to figure a way to separate these things (e.g. frontend actually talks to members API)
@@ -68,6 +69,65 @@ const getOfferData = async function (req, res) {
68
69
  });
69
70
  };
70
71
 
72
+ const getMemberNewsletters = async function (req, res) {
73
+ try {
74
+ const memberUuid = req.query.uuid;
75
+
76
+ if (!memberUuid) {
77
+ res.writeHead(400);
78
+ return res.end('Invalid member uuid');
79
+ }
80
+
81
+ const memberData = await membersService.api.members.get({
82
+ uuid: memberUuid
83
+ }, {
84
+ withRelated: ['newsletters']
85
+ });
86
+
87
+ if (!memberData) {
88
+ res.writeHead(404);
89
+ return res.end('Email address not found.');
90
+ }
91
+
92
+ const data = _.pick(memberData.toJSON(), 'uuid', 'email', 'name', 'newsletters', 'status');
93
+ return res.json(data);
94
+ } catch (err) {
95
+ res.writeHead(400);
96
+ res.end('Failed to unsubscribe this email address');
97
+ }
98
+ };
99
+
100
+ const updateMemberNewsletters = async function (req, res) {
101
+ try {
102
+ const memberUuid = req.query.uuid;
103
+ if (!memberUuid) {
104
+ res.writeHead(400);
105
+ return res.end('Invalid member uuid');
106
+ }
107
+
108
+ const data = _.pick(req.body, 'newsletters');
109
+ const memberData = await membersService.api.members.get({
110
+ uuid: memberUuid
111
+ });
112
+ if (!memberData) {
113
+ res.writeHead(404);
114
+ return res.end('Email address not found.');
115
+ }
116
+
117
+ const options = {
118
+ id: memberData.get('id'),
119
+ withRelated: ['newsletters']
120
+ };
121
+
122
+ const updatedMember = await membersService.api.members.update(data, options);
123
+ const updatedMemberData = _.pick(updatedMember.toJSON(), ['uuid', 'email', 'name', 'newsletters', 'status']);
124
+ res.json(updatedMemberData);
125
+ } catch (err) {
126
+ res.writeHead(400);
127
+ res.end('Failed to update newsletters');
128
+ }
129
+ };
130
+
71
131
  const updateMemberData = async function (req, res) {
72
132
  try {
73
133
  const data = _.pick(req.body, 'name', 'subscribed', 'newsletters');
@@ -134,8 +194,13 @@ const getPortalProductPrices = async function () {
134
194
  };
135
195
 
136
196
  const getSiteNewsletters = async function () {
137
- const newsletters = await models.Newsletter.findAll();
138
- return newsletters.toJSON();
197
+ try {
198
+ return await newslettersService.browse({filter: 'status:active', limit: 'all'});
199
+ } catch (err) {
200
+ logging.warn('Failed to fetch site newsletters');
201
+ logging.warn(err.message);
202
+ return [];
203
+ }
139
204
  };
140
205
 
141
206
  const getMemberSiteData = async function (req, res) {
@@ -264,9 +329,11 @@ module.exports = {
264
329
  loadMemberSession,
265
330
  createSessionFromMagicLink,
266
331
  getIdentityToken,
332
+ getMemberNewsletters,
267
333
  getMemberData,
268
334
  getOfferData,
269
335
  updateMemberData,
336
+ updateMemberNewsletters,
270
337
  getMemberSiteData,
271
338
  deleteSession
272
339
  };
@@ -53,22 +53,17 @@ const membersImporter = new MembersCSVImporter({
53
53
  isSet: labsService.isSet.bind(labsService),
54
54
  addJob: jobsService.addJob.bind(jobsService),
55
55
  knex: db.knex,
56
- urlFor: urlUtils.urlFor.bind(urlUtils)
56
+ urlFor: urlUtils.urlFor.bind(urlUtils),
57
+ context: {
58
+ importer: true
59
+ }
57
60
  });
58
61
 
59
62
  const processImport = async (options) => {
60
63
  const result = await membersImporter.process(options);
61
- const importSize = result.meta.originalImportSize;
62
- delete result.meta.originalImportSize;
63
-
64
- const importThreshold = await verificationTrigger.getImportThreshold();
65
- if (importSize > importThreshold) {
66
- await verificationTrigger.startVerificationProcess({
67
- amountImported: importSize,
68
- throwOnTrigger: true,
69
- source: 'import'
70
- });
71
- }
64
+
65
+ // Check whether all imports in last 30 days > threshold
66
+ await verificationTrigger.testImportThreshold();
72
67
 
73
68
  return result;
74
69
  };