ghost 5.8.3 → 5.9.2

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 (113) hide show
  1. package/components/tryghost-adapter-manager-0.0.0.tgz +0 -0
  2. package/components/tryghost-api-framework-0.0.0.tgz +0 -0
  3. package/components/tryghost-bootstrap-socket-0.0.0.tgz +0 -0
  4. package/components/tryghost-custom-theme-settings-service-0.0.0.tgz +0 -0
  5. package/components/tryghost-email-analytics-provider-mailgun-0.0.0.tgz +0 -0
  6. package/components/tryghost-email-analytics-service-0.0.0.tgz +0 -0
  7. package/components/tryghost-job-manager-0.0.0.tgz +0 -0
  8. package/components/tryghost-mailgun-client-0.0.0.tgz +0 -0
  9. package/components/tryghost-member-analytics-service-0.0.0.tgz +0 -0
  10. package/components/tryghost-members-api-0.0.0.tgz +0 -0
  11. package/components/tryghost-members-importer-0.0.0.tgz +0 -0
  12. package/components/tryghost-members-offers-0.0.0.tgz +0 -0
  13. package/components/tryghost-members-payments-0.0.0.tgz +0 -0
  14. package/components/tryghost-members-ssr-0.0.0.tgz +0 -0
  15. package/components/tryghost-members-stripe-service-0.0.0.tgz +0 -0
  16. package/components/tryghost-minifier-0.0.0.tgz +0 -0
  17. package/components/tryghost-mw-cache-control-0.0.0.tgz +0 -0
  18. package/components/tryghost-mw-error-handler-0.0.0.tgz +0 -0
  19. package/components/tryghost-oembed-service-0.0.0.tgz +0 -0
  20. package/components/tryghost-package-json-0.0.0.tgz +0 -0
  21. package/components/tryghost-session-service-0.0.0.tgz +0 -0
  22. package/components/tryghost-settings-path-manager-0.0.0.tgz +0 -0
  23. package/components/tryghost-update-check-service-0.0.0.tgz +0 -0
  24. package/content/themes/casper/README.md +1 -1
  25. package/content/themes/casper/assets/built/portal.min.js +3 -0
  26. package/content/themes/casper/assets/built/screen.css +1 -1
  27. package/content/themes/casper/assets/built/screen.css.map +1 -1
  28. package/content/themes/casper/assets/css/screen.css +119 -37
  29. package/content/themes/casper/package.json +3 -3
  30. package/content/themes/casper/partials/post-card.hbs +3 -1
  31. package/content/themes/casper/post.hbs +7 -7
  32. package/content/themes/casper/yarn.lock +69 -68
  33. package/core/boot.js +2 -0
  34. package/core/built/admin/assets/{chunk.143.cf66fd97f99af03c697f.js → chunk.143.4e4590d80452422483d8.js} +5 -5
  35. package/core/built/admin/assets/{chunk.178.e95388dfcc564cdb1ecc.js → chunk.178.16d88956c09eb76205e0.js} +4 -4
  36. package/core/built/admin/assets/{chunk.351.cbc224ca65c14ef5322d.js → chunk.351.73f27952f867334a8228.js} +3 -3
  37. package/core/built/admin/assets/{chunk.351.cbc224ca65c14ef5322d.js.LICENSE.txt → chunk.351.73f27952f867334a8228.js.LICENSE.txt} +0 -0
  38. package/core/built/admin/assets/{ghost-b469423d0fbe5e40af17b560f7e3cead.css → ghost-0319628ec5dcb83e6968e99eaa629a75.css} +1 -1
  39. package/core/built/admin/assets/{ghost-dark-bcb6f4517a2dfe23a0a280632bfca00c.css → ghost-dark-eada0a988277e84b4c8ead995f912044.css} +1 -1
  40. package/core/built/admin/assets/{ghost-f1d63ad9698b7d38261df6384513c952.js → ghost-f144e03b578116a5d0ed41bceaf2bab8.js} +90 -88
  41. package/core/built/admin/index.html +5 -5
  42. package/core/frontend/helpers/comment_count.js +1 -15
  43. package/core/frontend/helpers/comments.js +4 -16
  44. package/core/frontend/helpers/ghost_head.js +1 -1
  45. package/core/frontend/services/routing/controllers/unsubscribe.js +3 -0
  46. package/core/server/api/endpoints/comments-members.js +23 -1
  47. package/core/server/api/endpoints/index.js +52 -52
  48. package/core/server/api/endpoints/oembed.js +1 -1
  49. package/core/server/api/endpoints/utils/serializers/input/comments.js +18 -0
  50. package/core/server/api/endpoints/utils/serializers/input/db.js +1 -1
  51. package/core/server/api/endpoints/utils/serializers/input/index.js +4 -0
  52. package/core/server/api/endpoints/utils/serializers/input/integrations.js +2 -2
  53. package/core/server/api/{shared → endpoints/utils}/serializers/input/utils/settings-filter-type-group-mapper.js +0 -0
  54. package/core/server/api/{shared → endpoints/utils}/serializers/input/utils/settings-key-group-mapper.js +0 -0
  55. package/core/server/api/{shared → endpoints/utils}/serializers/input/utils/settings-key-type-mapper.js +0 -0
  56. package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +12 -12
  57. package/core/server/api/endpoints/utils/serializers/output/tiers.js +1 -1
  58. package/core/server/api/index.js +0 -2
  59. package/core/server/data/importer/importers/data/settings.js +2 -2
  60. package/core/server/data/migrations/versions/5.9/2022-08-09-08-32-added-new-integration-type.js +24 -0
  61. package/core/server/data/schema/clients/mysql.js +0 -15
  62. package/core/server/data/schema/commands.js +0 -9
  63. package/core/server/data/schema/fixtures/fixtures.json +1 -1
  64. package/core/server/data/schema/schema.js +3 -3
  65. package/core/server/models/base/plugins/user-type.js +1 -9
  66. package/core/server/models/comment.js +96 -15
  67. package/core/server/models/label.js +14 -0
  68. package/core/server/models/newsletter.js +21 -0
  69. package/core/server/models/tag.js +20 -0
  70. package/core/server/models/user.js +20 -0
  71. package/core/server/services/auth/api-key/admin.js +1 -5
  72. package/core/server/services/auth/api-key/content.js +1 -5
  73. package/core/server/services/bulk-email/bulk-email-processor.js +19 -12
  74. package/core/server/services/bulk-email/index.js +1 -17
  75. package/core/server/services/comments/controller.js +9 -0
  76. package/core/server/services/comments/email-templates/new-comment-reply.hbs +3 -3
  77. package/core/server/services/comments/email-templates/new-comment-reply.txt.js +1 -1
  78. package/core/server/services/comments/email-templates/new-comment.hbs +2 -2
  79. package/core/server/services/comments/email-templates/report.hbs +2 -2
  80. package/core/server/services/comments/emails.js +2 -1
  81. package/core/server/services/comments/service.js +16 -3
  82. package/core/server/services/mega/post-email-serializer.js +11 -6
  83. package/core/server/services/permissions/can-this.js +154 -161
  84. package/core/server/services/permissions/parse-context.js +1 -8
  85. package/core/server/services/webhooks/serialize.js +3 -3
  86. package/core/server/web/api/endpoints/admin/routes.js +1 -1
  87. package/core/server/web/api/endpoints/content/routes.js +1 -1
  88. package/core/server/web/comments/routes.js +2 -1
  89. package/core/server/web/shared/middleware/index.js +1 -1
  90. package/core/shared/config/defaults.json +2 -2
  91. package/core/shared/labs.js +0 -1
  92. package/package.json +34 -37
  93. package/yarn.lock +272 -245
  94. package/core/server/api/README.md +0 -130
  95. package/core/server/api/shared/frame.js +0 -95
  96. package/core/server/api/shared/headers.js +0 -152
  97. package/core/server/api/shared/http.js +0 -127
  98. package/core/server/api/shared/index.js +0 -25
  99. package/core/server/api/shared/pipeline.js +0 -259
  100. package/core/server/api/shared/serializers/handle.js +0 -140
  101. package/core/server/api/shared/serializers/index.js +0 -13
  102. package/core/server/api/shared/serializers/input/all.js +0 -41
  103. package/core/server/api/shared/serializers/input/index.js +0 -5
  104. package/core/server/api/shared/serializers/output/index.js +0 -1
  105. package/core/server/api/shared/utils/index.js +0 -5
  106. package/core/server/api/shared/utils/options.js +0 -23
  107. package/core/server/api/shared/validators/handle.js +0 -68
  108. package/core/server/api/shared/validators/index.js +0 -9
  109. package/core/server/api/shared/validators/input/all.js +0 -213
  110. package/core/server/api/shared/validators/input/index.js +0 -5
  111. package/core/server/services/bulk-email/mailgun.js +0 -122
  112. package/core/server/services/oembed.js +0 -346
  113. package/core/server/web/shared/middleware/cache-control.js +0 -43
@@ -672,7 +672,7 @@
672
672
  "slug": "ghost-explore",
673
673
  "name": "Ghost Explore",
674
674
  "description": "Built-in Ghost Explore integration",
675
- "type": "builtin",
675
+ "type": "core",
676
676
  "api_keys": [{"type": "admin", "role": "Ghost Explore Integration"}]
677
677
  },
678
678
  {
@@ -319,7 +319,7 @@ module.exports = {
319
319
  maxlength: 50,
320
320
  nullable: false,
321
321
  defaultTo: 'custom',
322
- validations: {isIn: [['internal', 'builtin', 'custom']]}
322
+ validations: {isIn: [['internal', 'builtin', 'custom', 'core']]}
323
323
  },
324
324
  name: {type: 'string', maxlength: 191, nullable: false},
325
325
  slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
@@ -440,9 +440,9 @@ module.exports = {
440
440
  stripe_coupon_id: {type: 'string', maxlength: 255, nullable: true, unique: true},
441
441
  interval: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['month', 'year']]}},
442
442
  currency: {type: 'string', maxlength: 50, nullable: true},
443
- discount_type: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['percent', 'amount']]}},
443
+ discount_type: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['percent', 'amount', 'trial']]}},
444
444
  discount_amount: {type: 'integer', nullable: false},
445
- duration: {type: 'string', maxlength: 50, nullable: false},
445
+ duration: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['trial', 'once', 'repeating', 'forever']]}},
446
446
  duration_in_months: {type: 'integer', nullable: true},
447
447
  portal_title: {type: 'string', maxlength: 191, nullable: true},
448
448
  portal_description: {type: 'string', maxlength: 2000, nullable: true},
@@ -33,7 +33,7 @@ module.exports = function (Bookshelf) {
33
33
  options = options || {};
34
34
  options.context = options.context || {};
35
35
 
36
- if (options.context.user || Bookshelf.Model.isExternalUser(options.context.user)) {
36
+ if (options.context.user) {
37
37
  return options.context.user;
38
38
  } else if (options.context.integration) {
39
39
  /**
@@ -64,8 +64,6 @@ module.exports = function (Bookshelf) {
64
64
  return Bookshelf.Model.internalUser;
65
65
  } else if (this.get('id')) {
66
66
  return this.get('id');
67
- } else if (options.context.external) {
68
- return Bookshelf.Model.externalUser;
69
67
  } else {
70
68
  throw new errors.NotFoundError({
71
69
  message: tpl(messages.missingContext),
@@ -80,15 +78,9 @@ module.exports = function (Bookshelf) {
80
78
  * context.user ? true : false (if context.user is 0 as number, this condition is false)
81
79
  */
82
80
  internalUser: 1,
83
- externalUser: 0,
84
81
 
85
82
  isInternalUser: function isInternalUser(id) {
86
83
  return id === Bookshelf.Model.internalUser || id === Bookshelf.Model.internalUser.toString();
87
- },
88
-
89
- isExternalUser: function isExternalUser(id) {
90
- return id === Bookshelf.Model.externalUser || id === Bookshelf.Model.externalUser.toString();
91
84
  }
92
-
93
85
  });
94
86
  };
@@ -8,8 +8,7 @@ const messages = {
8
8
  emptyComment: 'The body of a comment cannot be empty',
9
9
  commentNotFound: 'Comment could not be found',
10
10
  notYourCommentToEdit: 'You may only edit your own comments',
11
- notYourCommentToDestroy: 'You may only delete your own comments',
12
- cannotEditDeletedComment: 'You may only edit published comments'
11
+ notYourCommentToDestroy: 'You may only delete your own comments'
13
12
  };
14
13
 
15
14
  /**
@@ -52,7 +51,10 @@ const Comment = ghostBookshelf.Model.extend({
52
51
  },
53
52
 
54
53
  replies() {
55
- return this.hasMany('Comment', 'parent_id');
54
+ return this.hasMany('Comment', 'parent_id', 'id')
55
+ .query('orderBy', 'created_at', 'ASC')
56
+ // Note: this limit is not working
57
+ .query('limit', 3);
56
58
  },
57
59
 
58
60
  emitChange: function emitChange(event, options) {
@@ -63,12 +65,6 @@ const Comment = ghostBookshelf.Model.extend({
63
65
  onSaving() {
64
66
  ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);
65
67
 
66
- if (this.hasChanged('html') && this.get('status') !== 'published') {
67
- throw new ValidationError({
68
- message: tpl(messages.cannotEditDeletedComment)
69
- });
70
- }
71
-
72
68
  if (this.hasChanged('html')) {
73
69
  const sanitizeHtml = require('sanitize-html');
74
70
 
@@ -105,13 +101,16 @@ const Comment = ghostBookshelf.Model.extend({
105
101
  },
106
102
 
107
103
  enforcedFilters: function enforcedFilters(options) {
108
- if (options.context && options.context.user) {
109
- return null;
104
+ // Convenicence option to merge all filters with parent_id:null filter
105
+ if (options.parentId !== undefined) {
106
+ if (options.parentId === null) {
107
+ return 'parent_id:null';
108
+ }
109
+ return 'parent_id:' + options.parentId;
110
110
  }
111
111
 
112
- return 'parent_id:null';
112
+ return null;
113
113
  }
114
-
115
114
  }, {
116
115
  destroy: function destroy(unfilteredOptions) {
117
116
  let options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});
@@ -181,12 +180,94 @@ const Comment = ghostBookshelf.Model.extend({
181
180
  */
182
181
  defaultRelations: function defaultRelations(methodName, options) {
183
182
  // @todo: the default relations are not working for 'add' when we add it below
184
- if (['findAll', 'findPage', 'edit', 'findOne'].indexOf(methodName) !== -1) {
183
+ if (['findAll', 'findPage', 'edit', 'findOne', 'destroy'].indexOf(methodName) !== -1) {
185
184
  if (!options.withRelated || options.withRelated.length === 0) {
186
- options.withRelated = ['member', 'likes', 'replies', 'replies.member', 'replies.likes'];
185
+ if (options.parentId) {
186
+ // Do not include replies for replies
187
+ options.withRelated = [
188
+ // Relations
189
+ 'member', 'count.likes', 'count.liked'
190
+ ];
191
+ } else {
192
+ options.withRelated = [
193
+ // Relations
194
+ 'member', 'count.replies', 'count.likes', 'count.liked',
195
+ // Replies (limited to 3)
196
+ 'replies', 'replies.member' , 'replies.count.likes', 'replies.count.liked'
197
+ ];
198
+ }
187
199
  }
188
200
  }
189
201
 
202
+ return options;
203
+ },
204
+
205
+ async findPage(options) {
206
+ const {withRelated} = this.defaultRelations('findPage', options);
207
+
208
+ const relationsToLoadIndividually = [
209
+ 'replies',
210
+ 'replies.member',
211
+ 'replies.count.likes',
212
+ 'replies.count.liked'
213
+ ].filter(relation => withRelated.includes(relation));
214
+
215
+ const result = await ghostBookshelf.Model.findPage.call(this, options);
216
+
217
+ for (const model of result.data) {
218
+ await model.load(relationsToLoadIndividually, _.omit(options, 'withRelated'));
219
+ }
220
+
221
+ return result;
222
+ },
223
+
224
+ countRelations() {
225
+ return {
226
+ replies(modelOrCollection) {
227
+ modelOrCollection.query('columns', 'comments.*', (qb) => {
228
+ qb.count('replies.id')
229
+ .from('comments AS replies')
230
+ .whereRaw('replies.parent_id = comments.id')
231
+ .as('count__replies');
232
+ });
233
+ },
234
+ likes(modelOrCollection) {
235
+ modelOrCollection.query('columns', 'comments.*', (qb) => {
236
+ qb.count('comment_likes.id')
237
+ .from('comment_likes')
238
+ .whereRaw('comment_likes.comment_id = comments.id')
239
+ .as('count__likes');
240
+ });
241
+ },
242
+ liked(modelOrCollection, options) {
243
+ modelOrCollection.query('columns', 'comments.*', (qb) => {
244
+ if (options.context && options.context.member && options.context.member.id) {
245
+ qb.count('comment_likes.id')
246
+ .from('comment_likes')
247
+ .whereRaw('comment_likes.comment_id = comments.id')
248
+ .where('comment_likes.member_id', options.context.member.id)
249
+ .as('count__liked');
250
+ return;
251
+ }
252
+
253
+ // Return zero
254
+ qb.select(ghostBookshelf.knex.raw('0')).as('count__liked');
255
+ });
256
+ }
257
+ };
258
+ },
259
+
260
+ /**
261
+ * Returns an array of keys permitted in a method's `options` hash, depending on the current method.
262
+ * @param {String} methodName The name of the method to check valid options for.
263
+ * @return {Array} Keys allowed in the `options` hash of the model's method.
264
+ */
265
+ permittedOptions: function permittedOptions(methodName) {
266
+ let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
267
+
268
+ // The comment model additionally supports having a parentId option
269
+ options.push('parentId');
270
+
190
271
  return options;
191
272
  }
192
273
  });
@@ -107,6 +107,20 @@ Label = ghostBookshelf.Model.extend({
107
107
  return options;
108
108
  },
109
109
 
110
+ countRelations() {
111
+ return {
112
+ members(modelOrCollection) {
113
+ modelOrCollection.query('columns', 'labels.*', (qb) => {
114
+ qb.count('members.id')
115
+ .from('members')
116
+ .leftOuterJoin('members_labels', 'members.id', 'members_labels.member_id')
117
+ .whereRaw('members_labels.label_id = labels.id')
118
+ .as('count__members');
119
+ });
120
+ }
121
+ };
122
+ },
123
+
110
124
  destroy: function destroy(unfilteredOptions) {
111
125
  const options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});
112
126
  options.withRelated = ['members'];
@@ -126,6 +126,27 @@ const Newsletter = ghostBookshelf.Model.extend({
126
126
  return options;
127
127
  },
128
128
 
129
+ countRelations() {
130
+ return {
131
+ posts(modelOrCollection) {
132
+ modelOrCollection.query('columns', 'newsletters.*', (qb) => {
133
+ qb.count('posts.id')
134
+ .from('posts')
135
+ .whereRaw('posts.newsletter_id = newsletters.id')
136
+ .as('count__posts');
137
+ });
138
+ },
139
+ members(modelOrCollection) {
140
+ modelOrCollection.query('columns', 'newsletters.*', (qb) => {
141
+ qb.count('members_newsletters.id')
142
+ .from('members_newsletters')
143
+ .whereRaw('members_newsletters.newsletter_id = newsletters.id')
144
+ .as('count__members');
145
+ });
146
+ }
147
+ };
148
+ },
149
+
129
150
  orderDefaultRaw: function () {
130
151
  return 'sort_order ASC, created_at ASC, id ASC';
131
152
  },
@@ -182,6 +182,26 @@ Tag = ghostBookshelf.Model.extend({
182
182
  return options;
183
183
  },
184
184
 
185
+ countRelations() {
186
+ return {
187
+ posts(modelOrCollection, options) {
188
+ modelOrCollection.query('columns', 'tags.*', (qb) => {
189
+ qb.count('posts.id')
190
+ .from('posts')
191
+ .leftOuterJoin('posts_tags', 'posts.id', 'posts_tags.post_id')
192
+ .whereRaw('posts_tags.tag_id = tags.id')
193
+ .as('count__posts');
194
+
195
+ if (options.context && options.context.public) {
196
+ // @TODO use the filter behavior for posts
197
+ qb.andWhere('posts.type', '=', 'post');
198
+ qb.andWhere('posts.status', '=', 'published');
199
+ }
200
+ });
201
+ }
202
+ };
203
+ },
204
+
185
205
  destroy: function destroy(unfilteredOptions) {
186
206
  const options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});
187
207
  options.withRelated = ['posts'];
@@ -426,6 +426,26 @@ User = ghostBookshelf.Model.extend({
426
426
  return permittedOptionsToReturn;
427
427
  },
428
428
 
429
+ countRelations() {
430
+ return {
431
+ posts(modelOrCollection, options) {
432
+ modelOrCollection.query('columns', 'users.*', (qb) => {
433
+ qb.count('posts.id')
434
+ .from('posts')
435
+ .join('posts_authors', 'posts.id', 'posts_authors.post_id')
436
+ .whereRaw('posts_authors.author_id = users.id')
437
+ .as('count__posts');
438
+
439
+ if (options.context && options.context.public) {
440
+ // @TODO use the filter behavior for posts
441
+ qb.andWhere('posts.type', '=', 'post');
442
+ qb.andWhere('posts.status', '=', 'published');
443
+ }
444
+ });
445
+ }
446
+ };
447
+ },
448
+
429
449
  /**
430
450
  * ### Find One
431
451
  *
@@ -127,11 +127,7 @@ const authenticateWithToken = async (req, res, next, {token, JWT_OPTIONS}) => {
127
127
 
128
128
  // CASE: blocking all non-internal: "custom" and "builtin" integration requests when the limit is reached
129
129
  if (limitService.isLimited('customIntegrations')
130
- && (apiKey.relations.integration
131
- && !['internal'].includes(apiKey.relations.integration.get('type'))
132
- && !['ghost-explore'].includes(apiKey.relations.integration.get('slug'))
133
- )
134
- ) {
130
+ && (apiKey.relations.integration && !['internal', 'core'].includes(apiKey.relations.integration.get('type')))) {
135
131
  // NOTE: using "checkWouldGoOverLimit" instead of "checkIsOverLimit" here because flag limits don't have
136
132
  // a concept of measuring if the limit has been surpassed
137
133
  await limitService.errorIfWouldGoOverLimit('customIntegrations');
@@ -43,11 +43,7 @@ const authenticateContentApiKey = async function authenticateContentApiKey(req,
43
43
 
44
44
  // CASE: blocking all non-internal: "custom" and "builtin" integration requests when the limit is reached
45
45
  if (limitService.isLimited('customIntegrations')
46
- && (apiKey.relations.integration
47
- && !['internal'].includes(apiKey.relations.integration.get('type'))
48
- && !['ghost-explore'].includes(apiKey.relations.integration.get('slug'))
49
- )
50
- ) {
46
+ && (apiKey.relations.integration && !['internal', 'core'].includes(apiKey.relations.integration.get('type')))) {
51
47
  // NOTE: using "checkWouldGoOverLimit" instead of "checkIsOverLimit" here because flag limits don't have
52
48
  // a concept of measuring if the limit has been surpassed
53
49
  await limitService.errorIfWouldGoOverLimit('customIntegrations');
@@ -5,17 +5,19 @@ const errors = require('@tryghost/errors');
5
5
  const tpl = require('@tryghost/tpl');
6
6
  const logging = require('@tryghost/logging');
7
7
  const models = require('../../models');
8
- const mailgunProvider = require('./mailgun');
8
+ const MailgunClient = require('@tryghost/mailgun-client');
9
9
  const sentry = require('../../../shared/sentry');
10
10
  const labs = require('../../../shared/labs');
11
11
  const debug = require('@tryghost/debug')('mega');
12
12
  const postEmailSerializer = require('../mega/post-email-serializer');
13
+ const configService = require('../../../shared/config');
14
+ const settingsCache = require('../../../shared/settings-cache');
13
15
 
14
16
  const messages = {
15
17
  error: 'The email service was unable to send an email batch.'
16
18
  };
17
19
 
18
- const BATCH_SIZE = mailgunProvider.BATCH_SIZE;
20
+ const mailgunClient = new MailgunClient({config: configService, settings: settingsCache});
19
21
 
20
22
  /**
21
23
  * An object representing batch request result
@@ -64,7 +66,7 @@ class FailedBatch extends BatchResultBase {
64
66
  */
65
67
 
66
68
  module.exports = {
67
- BATCH_SIZE,
69
+ BATCH_SIZE: MailgunClient.BATCH_SIZE,
68
70
  SuccessfulBatch,
69
71
  FailedBatch,
70
72
 
@@ -211,11 +213,12 @@ module.exports = {
211
213
  * @param {Email-like} emailData - The email to send, must be a POJO so emailModel.toJSON() before calling if needed
212
214
  * @param {[EmailRecipient]} recipients - The recipients to send the email to with their associated data
213
215
  * @param {string?} memberSegment - The member segment of the recipients
214
- * @returns {Object} - {providerId: 'xxx'}
216
+ * @returns {Promise<Object>} - {providerId: 'xxx'}
215
217
  */
216
- send(emailData, recipients, memberSegment) {
217
- const mailgunInstance = mailgunProvider.getInstance();
218
- if (!mailgunInstance) {
218
+ async send(emailData, recipients, memberSegment) {
219
+ const mailgunConfigured = mailgunClient.isConfigured();
220
+ if (!mailgunConfigured) {
221
+ logging.warn('Bulk email has not been configured');
219
222
  return;
220
223
  }
221
224
 
@@ -231,7 +234,7 @@ module.exports = {
231
234
  // static data for every recipient
232
235
  const data = {
233
236
  unique_id: recipient.member_uuid,
234
- unsubscribe_url: postEmailSerializer.createUnsubscribeUrl(recipient.member_uuid, newsletterUuid)
237
+ unsubscribe_url: postEmailSerializer.createUnsubscribeUrl(recipient.member_uuid, {newsletterUuid})
235
238
  };
236
239
 
237
240
  // computed properties on recipients - TODO: better way of handling these
@@ -247,10 +250,11 @@ module.exports = {
247
250
 
248
251
  emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment);
249
252
 
250
- return mailgunProvider.send(emailData, recipientData, replacements).then((response) => {
253
+ try {
254
+ const response = await mailgunClient.send(emailData, recipientData, replacements);
251
255
  debug(`sent message (${Date.now() - startTime}ms)`);
252
256
  return response;
253
- }).catch((error) => {
257
+ } catch (error) {
254
258
  // REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#errors
255
259
  let ghostError = new errors.EmailError({
256
260
  err: error,
@@ -263,6 +267,9 @@ module.exports = {
263
267
 
264
268
  debug(`failed to send message (${Date.now() - startTime}ms)`);
265
269
  throw ghostError;
266
- });
267
- }
270
+ }
271
+ },
272
+
273
+ // NOTE: for testing only!
274
+ _mailgunClient: mailgunClient
268
275
  };
@@ -1,17 +1 @@
1
- const {
2
- BATCH_SIZE,
3
- SuccessfulBatch,
4
- FailedBatch,
5
- processEmail,
6
- processEmailBatch,
7
- send
8
- } = require('./bulk-email-processor');
9
-
10
- module.exports = {
11
- BATCH_SIZE,
12
- SuccessfulBatch,
13
- FailedBatch,
14
- processEmail,
15
- processEmailBatch,
16
- send
17
- };
1
+ module.exports = require('./bulk-email-processor');
@@ -1,3 +1,5 @@
1
+ const _ = require('lodash');
2
+
1
3
  /**
2
4
  * @typedef {import('../../api/shared/frame')} Frame
3
5
  */
@@ -26,6 +28,13 @@ module.exports = class CommentsController {
26
28
  return this.service.getComments(frame.options);
27
29
  }
28
30
 
31
+ /**
32
+ * @param {Frame} frame
33
+ */
34
+ async replies(frame) {
35
+ return this.service.getReplies(frame.options.id, _.omit(frame.options, 'id'));
36
+ }
37
+
29
38
  /**
30
39
  * @param {Frame} frame
31
40
  */
@@ -94,7 +94,7 @@
94
94
  border-bottom-color: #EEF5F8;
95
95
  }
96
96
  a {
97
- color: #3A464C;
97
+ color: #15212A;
98
98
  }
99
99
  </style>
100
100
  </head>
@@ -116,7 +116,7 @@
116
116
  <tr>
117
117
  <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
118
118
  <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 25px; margin: 0; margin-bottom: 15px;">Hey there,</p>
119
- <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">Someone just replied to your comment on <a href="{{postUrl}}" target="_blank" style="font-weight: bold; text-decoration: underline;">{{postTitle}}</a>.</p>
119
+ <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">Someone just replied to your comment on <a href="{{postUrl}}" target="_blank" style="font-weight: bold; text-decoration: underline; color: #15212A;">{{postTitle}}</a>.</p>
120
120
 
121
121
  <table width="100" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F9F9FA; border-radius: 7px;">
122
122
  <tbody>
@@ -174,7 +174,7 @@
174
174
  </tr>
175
175
  <tr>
176
176
  <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-top: 2px">
177
- <p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0; margin-bottom: 2px;"><a class="small" href="{{profileUrl}}" style="text-decoration: underline; color: #738A94; font-size: 11px;">Manage your email preferences</a></p>
177
+ <p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0; margin-bottom: 2px;"><a class="small" href="{{profileUrl}}" style="text-decoration: underline; color: #738A94; font-size: 11px;">Unsubscribe from comment reply notifications</a></p>
178
178
  </td>
179
179
  </tr>
180
180
 
@@ -9,5 +9,5 @@ ${data.postUrl}#ghost-comments
9
9
  ---
10
10
 
11
11
  Sent to ${data.toEmail} from ${data.siteDomain}.
12
- You can manage your notification preferences at ${data.profileUrl}.`;
12
+ You can unsubscribe from these notifications at ${data.profileUrl}.`;
13
13
  };
@@ -94,7 +94,7 @@
94
94
  border-bottom-color: #EEF5F8;
95
95
  }
96
96
  a {
97
- color: #3A464C;
97
+ color: #15212A;
98
98
  }
99
99
  </style>
100
100
  </head>
@@ -116,7 +116,7 @@
116
116
  <tr>
117
117
  <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
118
118
  <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 25px; margin: 0; margin-bottom: 15px;">Hey there,</p>
119
- <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">Someone just left a comment on your post <a href="{{postUrl}}" target="_blank" style="font-weight: bold; text-decoration: underline;">{{postTitle}}</a>.</p>
119
+ <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">Someone just left a comment on your post <a href="{{postUrl}}" target="_blank" style="font-weight: bold; text-decoration: underline; color: #15212A;">{{postTitle}}</a>.</p>
120
120
 
121
121
  <table width="100" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F9F9FA; border-radius: 7px;">
122
122
  <tbody>
@@ -94,7 +94,7 @@
94
94
  border-bottom-color: #EEF5F8;
95
95
  }
96
96
  a {
97
- color: #3A464C;
97
+ color: #15212A;
98
98
  }
99
99
  </style>
100
100
  </head>
@@ -116,7 +116,7 @@
116
116
  <tr>
117
117
  <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
118
118
  <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 25px; margin: 0; margin-bottom: 15px;">Hey there,</p>
119
- <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">{{reporter}} has reported the comment below on <a href="{{postUrl}}" target="_blank">{{postTitle}}</a>. This comment will remain visible until you choose to remove it, which can be done directly on the post.</p>
119
+ <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">{{reporter}} has reported the comment below on <a href="{{postUrl}}" target="_blank" style="font-weight: bold; text-decoration: underline; color: #15212A;">{{postTitle}}</a>. This comment will remain visible until you choose to remove it, which can be done directly on the post.</p>
120
120
 
121
121
  <table width="100" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F9F9FA; border-radius: 7px;">
122
122
  <tbody>
@@ -2,6 +2,7 @@ const {promises: fs} = require('fs');
2
2
  const path = require('path');
3
3
  const moment = require('moment');
4
4
  const htmlToPlaintext = require('@tryghost/html-to-plaintext');
5
+ const postEmailSerializer = require('../mega/post-email-serializer');
5
6
 
6
7
  class CommentsServiceEmails {
7
8
  constructor({config, logging, models, mailer, settingsCache, urlService, urlUtils}) {
@@ -93,7 +94,7 @@ class CommentsServiceEmails {
93
94
  accentColor: this.settingsCache.get('accent_color'),
94
95
  fromEmail: this.notificationFromAddress,
95
96
  toEmail: to,
96
- profileUrl: `${this.urlUtils.getSiteUrl()}#/portal/account/profile`
97
+ profileUrl: postEmailSerializer.createUnsubscribeUrl(member.get('uuid'), {comments: true})
97
98
  };
98
99
 
99
100
  const {html, text} = await this.renderEmailTemplate('new-comment-reply', templateData);
@@ -115,7 +115,18 @@ class CommentsService {
115
115
  */
116
116
  async getComments(options) {
117
117
  this.checkEnabled();
118
- const page = await this.models.Comment.findPage(options);
118
+ const page = await this.models.Comment.findPage({...options, parentId: null});
119
+
120
+ return page;
121
+ }
122
+
123
+ /**
124
+ * @param {string} id - The ID of the Comment to get replies from
125
+ * @param {any} options
126
+ */
127
+ async getReplies(id, options) {
128
+ this.checkEnabled();
129
+ const page = await this.models.Comment.findPage({...options, parentId: id});
119
130
 
120
131
  return page;
121
132
  }
@@ -181,7 +192,8 @@ class CommentsService {
181
192
  commentId: model.id
182
193
  }));
183
194
 
184
- return model;
195
+ // Instead of returning the model, fetch it again, so we have all the relations properly fetched
196
+ return await this.models.Comment.findOne({id: model.id}, {...options, require: true});
185
197
  }
186
198
 
187
199
  /**
@@ -240,7 +252,8 @@ class CommentsService {
240
252
  commentId: model.id
241
253
  }));
242
254
 
243
- return model;
255
+ // Instead of returning the model, fetch it again, so we have all the relations properly fetched
256
+ return await this.models.Comment.findOne({id: model.id}, {...options, require: true});
244
257
  }
245
258
 
246
259
  /**