ghost 4.44.0 → 4.46.1

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 (108) 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-1e7dce606e92a03207d15ae7eb3d3c23.js → ghost.min-30e597cb65b62b31a9422ca9c0eb2890.js} +777 -632
  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-fe2c9b1235b4119b5406b788db2db434.js → vendor.min-97fd438f4772c5ec6bb30ad779b8530e.js} +862 -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/members.js +3 -0
  33. package/core/server/api/canary/newsletters.js +86 -4
  34. package/core/server/api/canary/stats.js +11 -2
  35. package/core/server/api/canary/utils/serializers/input/members.js +22 -0
  36. package/core/server/api/canary/utils/serializers/output/mappers/pages.js +1 -0
  37. package/core/server/api/canary/utils/serializers/output/mappers/posts.js +2 -0
  38. package/core/server/api/canary/utils/serializers/output/members.js +13 -5
  39. package/core/server/api/v2/utils/serializers/output/utils/mapper.js +2 -0
  40. package/core/server/api/v3/utils/serializers/output/utils/mapper.js +3 -0
  41. package/core/server/data/importer/importers/data/settings.js +0 -3
  42. package/core/server/data/migrations/utils.js +40 -0
  43. package/core/server/data/migrations/versions/4.43/2022-03-28-19-26-recreate-newsletter-table.js +1 -1
  44. package/core/server/data/migrations/versions/4.45/2022-04-19-12-23-backfill-subscriptions-offers.js +60 -0
  45. package/core/server/data/migrations/versions/4.45/2022-04-20-11-25-add-newsletter-read-permission.js +9 -0
  46. package/core/server/data/migrations/versions/4.45/2022-04-21-02-55-add-notifications-key-entry-to-settings-table.js +8 -0
  47. package/core/server/data/migrations/versions/4.46/2022-04-13-12-00-add-created-at-newsletters.js +6 -0
  48. package/core/server/data/migrations/versions/4.46/2022-04-13-12-01-add-updated-at-newsletters.js +6 -0
  49. package/core/server/data/migrations/versions/4.46/2022-04-13-12-02-fill-created-at-newsletters.js +19 -0
  50. package/core/server/data/migrations/versions/4.46/2022-04-13-12-03-drop-nullable-created-at-newsletters.js +3 -0
  51. package/core/server/data/migrations/versions/4.46/2022-04-13-12-08-newsletters-show-header-name.js +7 -0
  52. package/core/server/data/migrations/versions/4.46/2022-04-13-12-57-add-uuid-column-to-newsletters.js +8 -0
  53. package/core/server/data/migrations/versions/4.46/2022-04-13-12-58-fill-uuid-for-newsletters.js +19 -0
  54. package/core/server/data/migrations/versions/4.46/2022-04-13-12-59-drop-nullable-uuid-newsletters.js +3 -0
  55. package/core/server/data/migrations/versions/4.46/2022-04-13-13-00-add-default-newsletter.js +92 -0
  56. package/core/server/data/migrations/versions/4.46/2022-04-20-08-39-map-subscribers-to-default-newsletter.js +66 -0
  57. package/core/server/data/migrations/versions/4.46/2022-04-22-07-43-add-newsletter-id-to-subscribe-events.js +9 -0
  58. package/core/server/data/migrations/versions/4.46/2022-04-27-07-59-set-newsletter-id-subscribe-events.js +31 -0
  59. package/core/server/data/schema/commands.js +14 -0
  60. package/core/server/data/schema/default-settings/default-settings.json +4 -0
  61. package/core/server/data/schema/fixtures/fixtures.json +7 -1
  62. package/core/server/data/schema/schema.js +8 -3
  63. package/core/server/models/base/plugins/generate-slug.js +2 -2
  64. package/core/server/models/email.js +4 -0
  65. package/core/server/models/label.js +1 -1
  66. package/core/server/models/member-subscribe-event.js +4 -0
  67. package/core/server/models/member.js +26 -0
  68. package/core/server/models/newsletter.js +97 -14
  69. package/core/server/models/post.js +7 -4
  70. package/core/server/models/role.js +1 -1
  71. package/core/server/models/tag.js +1 -1
  72. package/core/server/models/user.js +1 -1
  73. package/core/server/services/api-version-compatibility/index.js +29 -0
  74. package/core/server/services/auth/members/index.js +1 -1
  75. package/core/server/services/mega/email-preview.js +4 -1
  76. package/core/server/services/mega/mega.js +83 -26
  77. package/core/server/services/mega/post-email-serializer.js +17 -14
  78. package/core/server/services/mega/template.js +24 -3
  79. package/core/server/services/members/api.js +2 -2
  80. package/core/server/services/members/middleware.js +69 -2
  81. package/core/server/services/members/service.js +4 -1
  82. package/core/server/services/newsletters/emails/verify-email.js +166 -0
  83. package/core/server/services/newsletters/index.js +14 -7
  84. package/core/server/services/newsletters/service.js +237 -6
  85. package/core/server/services/posts/posts-service.js +7 -9
  86. package/core/server/services/stats/service.js +2 -6
  87. package/core/server/services/users.js +20 -20
  88. package/core/server/web/admin/views/default-prod.html +4 -4
  89. package/core/server/web/admin/views/default.html +4 -4
  90. package/core/server/web/api/app.js +3 -0
  91. package/core/server/web/api/canary/admin/app.js +3 -0
  92. package/core/server/web/api/canary/admin/routes.js +3 -0
  93. package/core/server/web/api/canary/content/app.js +3 -0
  94. package/core/server/web/api/middleware/cors.js +1 -1
  95. package/core/server/web/api/v2/admin/app.js +3 -0
  96. package/core/server/web/api/v2/content/app.js +3 -0
  97. package/core/server/web/api/v3/admin/app.js +3 -0
  98. package/core/server/web/api/v3/content/app.js +3 -0
  99. package/core/server/web/members/app.js +5 -0
  100. package/core/shared/config/defaults.json +1 -1
  101. package/core/shared/labs.js +4 -2
  102. package/core/shared/settings-cache/public.js +1 -1
  103. package/package.json +69 -65
  104. package/yarn.lock +965 -620
  105. package/core/built/assets/ghost-dark-470c1ef06b10e5c40ad05f3a642eaaea.css +0 -1
  106. package/core/built/assets/ghost.min-d0c17e8314b5583c0df5d05fab3c051c.css +0 -1
  107. package/core/server/services/stats/lib/members-stats-service.js +0 -161
  108. package/core/server/services/stats/lib/mrr-stats-service.js +0 -154
@@ -676,6 +676,7 @@ Post = ghostBookshelf.Model.extend({
676
676
 
677
677
  // newsletter_id is read-only and should only be set using a query param when publishing/scheduling
678
678
  if (options.newsletter_id
679
+ && !this.get('newsletter_id')
679
680
  && this.hasChanged('status')
680
681
  && (newStatus === 'published' || newStatus === 'scheduled')) {
681
682
  this.set('newsletter_id', options.newsletter_id);
@@ -695,6 +696,7 @@ Post = ghostBookshelf.Model.extend({
695
696
  return self.related('email').fetch({transacting: options.transacting}).then((email) => {
696
697
  if (!email) {
697
698
  self.set('email_recipient_filter', 'none');
699
+ self.set('newsletter_id', null);
698
700
  }
699
701
  });
700
702
  });
@@ -826,6 +828,10 @@ Post = ghostBookshelf.Model.extend({
826
828
  return this.hasOne('Email', 'post_id');
827
829
  },
828
830
 
831
+ newsletter: function newsletter() {
832
+ return this.belongsTo('Newsletter', 'newsletter_id');
833
+ },
834
+
829
835
  /**
830
836
  * @NOTE:
831
837
  * If you are requesting models with `columns`, you try to only receive some fields of the model/s.
@@ -885,9 +891,6 @@ Post = ghostBookshelf.Model.extend({
885
891
  // CASE: never expose the revisions
886
892
  delete attrs.mobiledoc_revisions;
887
893
 
888
- // CASE: hide the newsletter_id for now
889
- delete attrs.newsletter_id;
890
-
891
894
  // If the current column settings allow it...
892
895
  if (!options.columns || (options.columns && options.columns.indexOf('primary_tag') > -1)) {
893
896
  // ... attach a computed property of primary_tag which is the first tag if it is public, else null
@@ -1019,7 +1022,7 @@ Post = ghostBookshelf.Model.extend({
1019
1022
  permittedOptions: function permittedOptions(methodName) {
1020
1023
  let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
1021
1024
 
1022
- // whitelists for the `options` hash argument on methods, by method name.
1025
+ // allowlists for the `options` hash argument on methods, by method name.
1023
1026
  // these are the only options that can be passed to Bookshelf / Knex.
1024
1027
  const validOptions = {
1025
1028
  findOne: ['columns', 'importing', 'withRelated', 'require', 'filter'],
@@ -42,7 +42,7 @@ Role = ghostBookshelf.Model.extend({
42
42
  permittedOptions: function permittedOptions(methodName) {
43
43
  let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
44
44
 
45
- // whitelists for the `options` hash argument on methods, by method name.
45
+ // allowlists for the `options` hash argument on methods, by method name.
46
46
  // these are the only options that can be passed to Bookshelf / Knex.
47
47
  const validOptions = {
48
48
  findOne: ['withRelated'],
@@ -163,7 +163,7 @@ Tag = ghostBookshelf.Model.extend({
163
163
  permittedOptions: function permittedOptions(methodName) {
164
164
  let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
165
165
 
166
- // whitelists for the `options` hash argument on methods, by method name.
166
+ // allowlists for the `options` hash argument on methods, by method name.
167
167
  // these are the only options that can be passed to Bookshelf / Knex.
168
168
  const validOptions = {
169
169
  findAll: ['columns'],
@@ -392,7 +392,7 @@ User = ghostBookshelf.Model.extend({
392
392
  permittedOptions: function permittedOptions(methodName, options) {
393
393
  let permittedOptionsToReturn = ghostBookshelf.Model.permittedOptions.call(this, methodName);
394
394
 
395
- // whitelists for the `options` hash argument on methods, by method name.
395
+ // allowlists for the `options` hash argument on methods, by method name.
396
396
  // these are the only options that can be passed to Bookshelf / Knex.
397
397
  const validOptions = {
398
398
  findOne: ['withRelated', 'status'],
@@ -0,0 +1,29 @@
1
+ const APIVersionCompatibilityService = require('@tryghost/api-version-compatibility-service');
2
+ const VersionNotificationsDataService = require('@tryghost/version-notifications-data-service');
3
+ // const {GhostMailer} = require('../mail');
4
+ const settingsService = require('../../services/settings');
5
+ const models = require('../../models');
6
+ const logging = require('@tryghost/logging');
7
+
8
+ const init = () => {
9
+ //const ghostMailer = new GhostMailer();
10
+ const versionNotificationsDataService = new VersionNotificationsDataService({
11
+ UserModel: models.User,
12
+ settingsService: settingsService.getSettingsBREADServiceInstance()
13
+ });
14
+
15
+ this.APIVersionCompatibilityServiceInstance = new APIVersionCompatibilityService({
16
+ sendEmail: (options) => {
17
+ // NOTE: not using bind here because mockMailer is having trouble mocking bound methods
18
+ //return ghostMailer.send(options);
19
+ // For now log a warning, rather than sending an email
20
+ logging.warn(options.html);
21
+ },
22
+ fetchEmailsToNotify: versionNotificationsDataService.getNotificationEmails.bind(versionNotificationsDataService),
23
+ fetchHandled: versionNotificationsDataService.fetchNotification.bind(versionNotificationsDataService),
24
+ saveHandled: versionNotificationsDataService.saveNotification.bind(versionNotificationsDataService)
25
+ });
26
+ };
27
+
28
+ module.exports.APIVersionCompatibilityServiceInstance;
29
+ module.exports.init = init;
@@ -1,4 +1,4 @@
1
- const jwt = require('express-jwt');
1
+ const {expressjwt: jwt} = require('express-jwt');
2
2
  const {UnauthorizedError} = require('@tryghost/errors');
3
3
  const membersService = require('../../members');
4
4
  const config = require('../../../../shared/config');
@@ -1,4 +1,5 @@
1
1
  const postEmailSerializer = require('./post-email-serializer');
2
+ const models = require('../../models');
2
3
 
3
4
  class EmailPreview {
4
5
  /**
@@ -16,7 +17,9 @@ class EmailPreview {
16
17
  * @returns {Promise<Object>}
17
18
  */
18
19
  async generateEmailContent(post, memberSegment) {
19
- let emailContent = await postEmailSerializer.serialize(post, {
20
+ const newsletter = await models.Newsletter.getDefaultNewsletter();
21
+
22
+ let emailContent = await postEmailSerializer.serialize(post, newsletter, {
20
23
  isBrowserPreview: true,
21
24
  apiVersion: this.apiVersion
22
25
  });
@@ -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
  /**
@@ -176,12 +216,16 @@ const addEmail = async (postModel, options) => {
176
216
  const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
177
217
  const filterOptions = Object.assign({}, knexOptions, {limit: 1});
178
218
 
219
+ let newsletter;
220
+ if (labsService.isSet('multipleNewsletters')) {
221
+ newsletter = await postModel.related('newsletter').fetch(Object.assign({}, {require: false}, _.pick(options, ['transacting'])));
222
+ }
179
223
  const emailRecipientFilter = postModel.get('email_recipient_filter');
180
- filterOptions.filter = transformEmailRecipientFilter(emailRecipientFilter, {errorProperty: 'email_recipient_filter'});
224
+ filterOptions.filter = transformEmailRecipientFilter(emailRecipientFilter, {errorProperty: 'email_recipient_filter'}, newsletter);
181
225
 
182
226
  const startRetrieve = Date.now();
183
227
  debug('addEmail: retrieving members count');
184
- 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});
185
229
  debug(`addEmail: retrieved members count - ${membersCount} members (${Date.now() - startRetrieve}ms)`);
186
230
 
187
231
  // NOTE: don't create email object when there's nobody to send the email to
@@ -273,7 +317,11 @@ async function handleUnsubscribeRequest(req) {
273
317
  }
274
318
 
275
319
  try {
276
- 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});
277
325
  return memberModel.toJSON();
278
326
  } catch (err) {
279
327
  throw new errors.InternalServerError({
@@ -298,11 +346,14 @@ async function pendingEmailHandler(emailModel, options) {
298
346
  const emailAnalyticsJobs = require('../email-analytics/jobs');
299
347
  emailAnalyticsJobs.scheduleRecurringJobs();
300
348
 
301
- return jobsService.addJob({
302
- job: sendEmailJob,
303
- data: {emailModel},
304
- offloaded: false
305
- });
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
+ }
306
357
  }
307
358
 
308
359
  async function sendEmailJob({emailModel, options}) {
@@ -377,7 +428,11 @@ async function getEmailMemberRows({emailModel, memberSegment, options}) {
377
428
  const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
378
429
  const filterOptions = Object.assign({}, knexOptions);
379
430
 
380
- 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);
381
436
  filterOptions.filter = recipientFilter;
382
437
 
383
438
  if (memberSegment) {
@@ -554,7 +609,9 @@ module.exports = {
554
609
  // NOTE: below are only exposed for testing purposes
555
610
  _transformEmailRecipientFilter: transformEmailRecipientFilter,
556
611
  _partitionMembersBySegment: partitionMembersBySegment,
557
- _getEmailMemberRows: getEmailMemberRows
612
+ _getEmailMemberRows: getEmailMemberRows,
613
+ _getFromAddress: getFromAddress,
614
+ _getReplyToAddress: getReplyToAddress
558
615
  };
559
616
 
560
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,7 +53,10 @@ 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) => {