ghost 5.119.3 → 5.120.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 (129) hide show
  1. package/components/tryghost-i18n-5.120.0.tgz +0 -0
  2. package/core/boot.js +0 -2
  3. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +7555 -7216
  4. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-60ce658c.mjs → CodeEditorView-1c5b0683.mjs} +2 -2
  5. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  6. package/core/built/admin/assets/admin-x-settings/{index-8480baa8.mjs → index-14e518a7.mjs} +3 -3
  7. package/core/built/admin/assets/admin-x-settings/{index-a2648c61.mjs → index-fc9f985b.mjs} +2 -2
  8. package/core/built/admin/assets/admin-x-settings/{modals-6900c1d5.mjs → modals-15bc6a0f.mjs} +7192 -6656
  9. package/core/built/admin/assets/{chunk.137.c9bf40f01afeeadb4660.js → chunk.383.25fca2f09b4896656125.js} +76 -59
  10. package/core/built/admin/assets/chunk.524.1657b12c0ab25dd9fb79.js +28 -0
  11. package/core/built/admin/assets/{chunk.582.2697b46a5652693fc674.js → chunk.582.09869b1f1a3cc0ab81f6.js} +19 -26
  12. package/core/built/admin/assets/{ghost-843572e9507d099162ae744d791daba1.js → ghost-b3b44421acca3b3eec76bfbb6ba0e81b.js} +3 -3
  13. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +12578 -12352
  14. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +423 -211
  15. package/core/built/admin/assets/posts/posts.js +13680 -13671
  16. package/core/built/admin/assets/stats/stats.js +16457 -16635
  17. package/core/built/admin/assets/{vendor-8f805740fee4db959a5b2119001a56b1.js → vendor-4ce6d282a2a00fe486a0951e0591da19.js} +11 -9
  18. package/core/built/admin/index.html +5 -5
  19. package/core/frontend/helpers/match.js +6 -0
  20. package/core/frontend/services/routing/ParentRouter.js +1 -1
  21. package/core/frontend/services/routing/controllers/email-post.js +0 -2
  22. package/core/frontend/services/routing/controllers/previews.js +0 -3
  23. package/core/frontend/web/middleware/frontend-caching.js +2 -2
  24. package/core/server/api/endpoints/authentication.js +37 -73
  25. package/core/server/api/endpoints/authors-public.js +8 -9
  26. package/core/server/api/endpoints/db.js +34 -35
  27. package/core/server/api/endpoints/emails.js +8 -10
  28. package/core/server/api/endpoints/integrations.js +20 -18
  29. package/core/server/api/endpoints/invites.js +8 -10
  30. package/core/server/api/endpoints/labels.js +19 -23
  31. package/core/server/api/endpoints/notifications.js +3 -4
  32. package/core/server/api/endpoints/pages-public.js +8 -10
  33. package/core/server/api/endpoints/pages.js +14 -18
  34. package/core/server/api/endpoints/posts-public.js +8 -10
  35. package/core/server/api/endpoints/posts.js +6 -8
  36. package/core/server/api/endpoints/previews.js +8 -10
  37. package/core/server/api/endpoints/redirects.js +7 -8
  38. package/core/server/api/endpoints/schedules.js +5 -7
  39. package/core/server/api/endpoints/slugs.js +7 -9
  40. package/core/server/api/endpoints/snippets.js +16 -20
  41. package/core/server/api/endpoints/tags-public.js +8 -10
  42. package/core/server/api/endpoints/tags.js +19 -23
  43. package/core/server/api/endpoints/themes.js +6 -8
  44. package/core/server/api/endpoints/users.js +31 -36
  45. package/core/server/api/endpoints/utils/permissions.js +10 -10
  46. package/core/server/api/endpoints/utils/serializers/output/roles.js +9 -10
  47. package/core/server/api/endpoints/utils/validators/input/images.js +43 -52
  48. package/core/server/api/endpoints/utils/validators/input/invites.js +6 -8
  49. package/core/server/api/endpoints/webhooks.js +38 -42
  50. package/core/server/data/migrations/versions/5.120/2025-05-07-14-57-38-add-newsletters-button-corners-column.js +8 -0
  51. package/core/server/data/migrations/versions/5.120/2025-05-13-17-36-56-add-newsletters-button-style-column.js +8 -0
  52. package/core/server/data/migrations/versions/5.120/2025-05-14-20-00-15-add-newsletters-setting-columns.js +22 -0
  53. package/core/server/data/schema/schema.js +6 -1
  54. package/core/server/lib/image/Gravatar.js +12 -13
  55. package/core/server/lib/lexical.js +3 -1
  56. package/core/server/models/newsletter.js +6 -1
  57. package/core/server/services/api-version-compatibility/index.js +1 -33
  58. package/core/server/services/auth/session/emails/signin.js +3 -3
  59. package/core/server/services/email-address/EmailAddressParser.js +52 -0
  60. package/core/server/services/email-address/EmailAddressParser.js.d.ts +13 -0
  61. package/core/server/services/email-address/EmailAddressService.js +142 -0
  62. package/core/server/services/email-address/EmailAddressService.ts +183 -0
  63. package/core/server/services/email-address/EmailAddressServiceWrapper.js +2 -4
  64. package/core/server/services/email-analytics/EmailAnalyticsService.js +1 -1
  65. package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +2 -1
  66. package/core/server/services/email-service/BatchSendingService.js +703 -0
  67. package/core/server/services/email-service/EmailBodyCache.js +20 -0
  68. package/core/server/services/email-service/EmailController.js +94 -0
  69. package/core/server/services/email-service/EmailEventProcessor.js +267 -0
  70. package/core/server/services/email-service/EmailEventStorage.js +187 -0
  71. package/core/server/services/email-service/EmailRenderer.js +1263 -0
  72. package/core/server/services/email-service/EmailSegmenter.js +74 -0
  73. package/core/server/services/email-service/EmailService.js +310 -0
  74. package/core/server/services/email-service/EmailServiceWrapper.js +9 -2
  75. package/core/server/services/email-service/MailgunEmailProvider.js +191 -0
  76. package/core/server/services/email-service/SendingService.js +173 -0
  77. package/core/server/services/email-service/email-templates/partials/feedback-button.hbs +7 -0
  78. package/core/server/services/email-service/email-templates/partials/latest-posts.hbs +39 -0
  79. package/core/server/services/email-service/email-templates/partials/paywall.hbs +20 -0
  80. package/core/server/services/email-service/email-templates/partials/styles.hbs +2348 -0
  81. package/core/server/services/email-service/email-templates/template.hbs +238 -0
  82. package/core/server/services/email-service/events/EmailBouncedEvent.js +63 -0
  83. package/core/server/services/email-service/events/EmailDeliveredEvent.js +49 -0
  84. package/core/server/services/email-service/events/EmailOpenedEvent.js +49 -0
  85. package/core/server/services/email-service/events/EmailTemporaryBouncedEvent.js +63 -0
  86. package/core/server/services/email-service/events/EmailUnsubscribedEvent.js +42 -0
  87. package/core/server/services/email-service/events/SpamComplaintEvent.js +42 -0
  88. package/core/server/services/email-service/helpers/register-helpers.js +59 -0
  89. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +2 -1
  90. package/core/server/services/explore-ping/index.js +2 -1
  91. package/core/server/services/mail/GhostMailer.js +1 -1
  92. package/core/server/services/media-inliner/ExternalMediaInliner.js +2 -1
  93. package/core/server/services/members/api.js +15 -15
  94. package/core/server/services/members/emails/signin.js +4 -4
  95. package/core/server/services/members/emails/signup-paid.js +3 -4
  96. package/core/server/services/members/emails/signup.js +3 -3
  97. package/core/server/services/members/emails/subscribe.js +3 -3
  98. package/core/server/services/members/members-api/repositories/MemberRepository.js +92 -92
  99. package/core/server/services/members-events/LastSeenAtUpdater.js +1 -1
  100. package/core/server/services/settings-helpers/SettingsHelpers.js +1 -1
  101. package/core/server/services/staff/StaffServiceEmails.js +1 -1
  102. package/core/server/services/stats/PostsStatsService.js +28 -7
  103. package/core/server/web/api/app.js +0 -1
  104. package/core/server/web/api/endpoints/admin/app.js +0 -2
  105. package/core/server/web/api/endpoints/content/app.js +0 -2
  106. package/core/server/web/api/middleware/upload.js +2 -2
  107. package/core/shared/custom-theme-settings-cache/CustomThemeSettingsService.js +2 -1
  108. package/package.json +39 -97
  109. package/tsconfig.tsbuildinfo +1 -1
  110. package/yarn.lock +385 -517
  111. package/components/tryghost-api-framework-5.119.3.tgz +0 -0
  112. package/components/tryghost-custom-fonts-5.119.3.tgz +0 -0
  113. package/components/tryghost-domain-events-5.119.3.tgz +0 -0
  114. package/components/tryghost-email-addresses-5.119.3.tgz +0 -0
  115. package/components/tryghost-email-service-5.119.3.tgz +0 -0
  116. package/components/tryghost-html-to-plaintext-5.119.3.tgz +0 -0
  117. package/components/tryghost-i18n-5.119.3.tgz +0 -0
  118. package/components/tryghost-job-manager-5.119.3.tgz +0 -0
  119. package/components/tryghost-members-csv-5.119.3.tgz +0 -0
  120. package/components/tryghost-mw-error-handler-5.119.3.tgz +0 -0
  121. package/components/tryghost-mw-vhost-5.119.3.tgz +0 -0
  122. package/components/tryghost-prometheus-metrics-5.119.3.tgz +0 -0
  123. package/components/tryghost-security-5.119.3.tgz +0 -0
  124. package/core/built/admin/assets/chunk.524.c86e2e1b3e94d7cb1e4c.js +0 -35
  125. package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +0 -99
  126. package/core/server/services/api-version-compatibility/VersionNotificationsDataService.js +0 -80
  127. package/core/server/services/api-version-compatibility/extract-api-key.js +0 -57
  128. package/core/server/services/api-version-compatibility/mw-api-version-mismatch.js +0 -31
  129. /package/core/built/admin/assets/{chunk.137.c9bf40f01afeeadb4660.js.LICENSE.txt → chunk.383.25fca2f09b4896656125.js.LICENSE.txt} +0 -0
@@ -10,21 +10,20 @@ const messages = {
10
10
  invalidFile: 'Icon must be a .jpg, .webp, .svg or .png file, at least 60x60px, under 20MB.'
11
11
  };
12
12
 
13
- const profileImage = (frame) => {
14
- return imageSize.getImageSizeFromPath(frame.file.path).then((response) => {
15
- // save the image dimensions in new property for file
16
- frame.file.dimensions = response;
13
+ const profileImage = async (frame) => {
14
+ const response = await imageSize.getImageSizeFromPath(frame.file.path);
15
+ // save the image dimensions in new property for file
16
+ frame.file.dimensions = response;
17
17
 
18
- // CASE: file needs to be a square
19
- if (frame.file.dimensions.width !== frame.file.dimensions.height) {
20
- return Promise.reject(new errors.ValidationError({
21
- message: tpl(messages.isNotSquare)
22
- }));
23
- }
24
- });
18
+ // CASE: file needs to be a square
19
+ if (frame.file.dimensions.width !== frame.file.dimensions.height) {
20
+ throw new errors.ValidationError({
21
+ message: tpl(messages.isNotSquare)
22
+ });
23
+ }
25
24
  };
26
25
 
27
- const icon = (frame) => {
26
+ const icon = async (frame) => {
28
27
  const iconExtensions = (config.get('uploads').icons && config.get('uploads').icons.extensions) || [];
29
28
 
30
29
  // We don't support resizing .ico files, so we set a lower max upload size
@@ -44,56 +43,48 @@ const icon = (frame) => {
44
43
 
45
44
  // CASE: file should not be larger than 20MB
46
45
  if (!validIconFileSize(frame.file.size)) {
47
- return Promise.reject(new errors.ValidationError({
46
+ throw new errors.ValidationError({
48
47
  message: tpl(message, {extensions: iconExtensions})
49
- }));
48
+ });
50
49
  }
51
50
 
52
- return blogIcon.getIconDimensions(frame.file.path).then((response) => {
53
- // save the image dimensions in new property for file
54
- frame.file.dimensions = response;
51
+ const response = await blogIcon.getIconDimensions(frame.file.path);
52
+ // save the image dimensions in new property for file
53
+ frame.file.dimensions = response;
55
54
 
56
- if (isIco) {
57
- // CASE: file needs to be a square
58
- if (frame.file.dimensions.width !== frame.file.dimensions.height) {
59
- return Promise.reject(new errors.ValidationError({
60
- message: tpl(message, {extensions: iconExtensions})
61
- }));
62
- }
63
-
64
- // CASE: icon needs to be smaller than or equal to 1000px
65
- if (frame.file.dimensions.width > 1000) {
66
- return Promise.reject(new errors.ValidationError({
67
- message: tpl(message, {extensions: iconExtensions})
68
- }));
69
- }
55
+ if (isIco) {
56
+ // CASE: file needs to be a square
57
+ if (frame.file.dimensions.width !== frame.file.dimensions.height) {
58
+ throw new errors.ValidationError({
59
+ message: tpl(message, {extensions: iconExtensions})
60
+ });
70
61
  }
71
62
 
72
- // CASE: icon needs to be bigger than or equal to 60px
73
- // .ico files can contain multiple sizes, we need at least a minimum of 60px (16px is ok, as long as 60px are present as well)
74
- if (!isSVG && frame.file.dimensions.width < 60) {
75
- return Promise.reject(new errors.ValidationError({
63
+ // CASE: icon needs to be smaller than or equal to 1000px
64
+ if (frame.file.dimensions.width > 1000) {
65
+ throw new errors.ValidationError({
76
66
  message: tpl(message, {extensions: iconExtensions})
77
- }));
67
+ });
78
68
  }
79
- });
69
+ }
70
+
71
+ // CASE: icon needs to be bigger than or equal to 60px
72
+ // .ico files can contain multiple sizes, we need at least a minimum of 60px (16px is ok, as long as 60px are present as well)
73
+ if (!isSVG && frame.file.dimensions.width < 60) {
74
+ throw new errors.ValidationError({
75
+ message: tpl(message, {extensions: iconExtensions})
76
+ });
77
+ }
80
78
  };
81
79
 
82
80
  module.exports = {
83
- upload(apiConfig, frame) {
84
- return Promise.resolve()
85
- .then(() => {
86
- return jsonSchema.validate(apiConfig, frame);
87
- })
88
- .then(() => {
89
- if (frame.data.purpose === 'profile_image') {
90
- return profileImage(frame);
91
- }
92
- })
93
- .then(() => {
94
- if (frame.data.purpose === 'icon') {
95
- return icon(frame);
96
- }
97
- });
81
+ async upload(apiConfig, frame) {
82
+ await jsonSchema.validate(apiConfig, frame);
83
+ if (frame.data.purpose === 'profile_image') {
84
+ await profileImage(frame);
85
+ }
86
+ if (frame.data.purpose === 'icon') {
87
+ await icon(frame);
88
+ }
98
89
  }
99
90
  };
@@ -7,14 +7,12 @@ const messages = {
7
7
  };
8
8
 
9
9
  module.exports = {
10
- add(apiConfig, frame) {
11
- return models.User.findOne({email: frame.data.invites[0].email}, frame.options)
12
- .then((user) => {
13
- if (user) {
14
- return Promise.reject(new errors.ValidationError({
15
- message: tpl(messages.userAlreadyRegistered)
16
- }));
17
- }
10
+ async add(apiConfig, frame) {
11
+ const user = await models.User.findOne({email: frame.data.invites[0].email}, frame.options);
12
+ if (user) {
13
+ throw new errors.ValidationError({
14
+ message: tpl(messages.userAlreadyRegistered)
18
15
  });
16
+ }
19
17
  }
20
18
  };
@@ -39,29 +39,27 @@ const controller = {
39
39
  cacheInvalidate: false
40
40
  },
41
41
  permissions: {
42
- before: (frame) => {
43
- if (frame.options.context && frame.options.context.integration && frame.options.context.integration.id) {
44
- return models.Webhook.findOne({id: frame.options.id})
45
- .then((webhook) => {
46
- if (!webhook) {
47
- throw new errors.NotFoundError({
48
- message: tpl(messages.resourceNotFound, {
49
- resource: 'Webhook'
50
- })
51
- });
52
- }
42
+ before: async (frame) => {
43
+ if (frame.options.context?.integration?.id) {
44
+ const webhook = await models.Webhook.findOne({id: frame.options.id});
45
+ if (!webhook) {
46
+ throw new errors.NotFoundError({
47
+ message: tpl(messages.resourceNotFound, {
48
+ resource: 'Webhook'
49
+ })
50
+ });
51
+ }
53
52
 
54
- if (webhook.get('integration_id') !== frame.options.context.integration.id) {
55
- throw new errors.NoPermissionError({
56
- message: tpl(messages.noPermissionToEdit.message, {
57
- method: 'edit'
58
- }),
59
- context: tpl(messages.noPermissionToEdit.context, {
60
- method: 'edit'
61
- })
62
- });
63
- }
53
+ if (webhook.get('integration_id') !== frame.options.context.integration.id) {
54
+ throw new errors.NoPermissionError({
55
+ message: tpl(messages.noPermissionToEdit.message, {
56
+ method: 'edit'
57
+ }),
58
+ context: tpl(messages.noPermissionToEdit.context, {
59
+ method: 'edit'
60
+ })
64
61
  });
62
+ }
65
63
  }
66
64
  }
67
65
  },
@@ -103,29 +101,27 @@ const controller = {
103
101
  }
104
102
  },
105
103
  permissions: {
106
- before: (frame) => {
107
- if (frame.options.context && frame.options.context.integration && frame.options.context.integration.id) {
108
- return models.Webhook.findOne({id: frame.options.id})
109
- .then((webhook) => {
110
- if (!webhook) {
111
- throw new errors.NotFoundError({
112
- message: tpl(messages.resourceNotFound, {
113
- resource: 'Webhook'
114
- })
115
- });
116
- }
104
+ before: async (frame) => {
105
+ if (frame.options.context?.integration?.id) {
106
+ const webhook = await models.Webhook.findOne({id: frame.options.id});
107
+ if (!webhook) {
108
+ throw new errors.NotFoundError({
109
+ message: tpl(messages.resourceNotFound, {
110
+ resource: 'Webhook'
111
+ })
112
+ });
113
+ }
117
114
 
118
- if (webhook.get('integration_id') !== frame.options.context.integration.id) {
119
- throw new errors.NoPermissionError({
120
- message: tpl(messages.noPermissionToEdit.message, {
121
- method: 'destroy'
122
- }),
123
- context: tpl(messages.noPermissionToEdit.context, {
124
- method: 'destroy'
125
- })
126
- });
127
- }
115
+ if (webhook.get('integration_id') !== frame.options.context.integration.id) {
116
+ throw new errors.NoPermissionError({
117
+ message: tpl(messages.noPermissionToEdit.message, {
118
+ method: 'destroy'
119
+ }),
120
+ context: tpl(messages.noPermissionToEdit.context, {
121
+ method: 'destroy'
122
+ })
128
123
  });
124
+ }
129
125
  }
130
126
  }
131
127
  },
@@ -0,0 +1,8 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('newsletters', 'button_corners', {
4
+ type: 'string',
5
+ maxlength: 50,
6
+ nullable: false,
7
+ defaultTo: 'rounded'
8
+ });
@@ -0,0 +1,8 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('newsletters', 'button_style', {
4
+ type: 'string',
5
+ maxlength: 50,
6
+ nullable: false,
7
+ defaultTo: 'fill'
8
+ });
@@ -0,0 +1,22 @@
1
+ const {combineNonTransactionalMigrations, createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = combineNonTransactionalMigrations(
4
+ createAddColumnMigration('newsletters', 'title_font_weight', {
5
+ type: 'string',
6
+ maxlength: 50,
7
+ nullable: false,
8
+ defaultTo: 'bold'
9
+ }),
10
+ createAddColumnMigration('newsletters', 'link_style', {
11
+ type: 'string',
12
+ maxlength: 50,
13
+ nullable: false,
14
+ defaultTo: 'underline'
15
+ }),
16
+ createAddColumnMigration('newsletters', 'image_corners', {
17
+ type: 'string',
18
+ maxlength: 50,
19
+ nullable: false,
20
+ defaultTo: 'square'
21
+ })
22
+ );
@@ -46,7 +46,12 @@ module.exports = {
46
46
  border_color: {type: 'string', maxlength: 50, nullable: true},
47
47
  title_color: {type: 'string', maxlength: 50, nullable: true},
48
48
  created_at: {type: 'dateTime', nullable: false},
49
- updated_at: {type: 'dateTime', nullable: true}
49
+ updated_at: {type: 'dateTime', nullable: true},
50
+ button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded', validations: {isIn: [['square', 'rounded', 'pill']]}},
51
+ button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill', validations: {isIn: [['fill', 'outline']]}},
52
+ title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold', validations: {isIn: [['normal', 'medium', 'semibold', 'bold']]}},
53
+ link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline', validations: {isIn: [['underline', 'regular', 'bold']]}},
54
+ image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square', validations: {isIn: [['square', 'rounded']]}}
50
55
  },
51
56
  posts: {
52
57
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
@@ -23,7 +23,7 @@ class Gravatar {
23
23
  return tpl(gravatarUrl, Object.assign(defaultOptions, options, {hash: emailHash}));
24
24
  }
25
25
 
26
- lookup(userData, timeout) {
26
+ async lookup(userData, timeout) {
27
27
  if (this.config.isPrivacyDisabled('useGravatar')) {
28
28
  return Promise.resolve();
29
29
  }
@@ -33,21 +33,20 @@ class Gravatar {
33
33
  const testUrl = this.url(userData.email, {default: 404, rating: 'x'});
34
34
  const imageUrl = this.url(userData.email, {default: 'mp', rating: 'x'});
35
35
 
36
- return Promise.resolve(this.request(testUrl, {timeout: {request: timeout || 2 * 1000}}))
37
- .then(function () {
36
+ try {
37
+ await this.request(testUrl, {timeout: {request: timeout || 2 * 1000}});
38
+ return {
39
+ image: imageUrl
40
+ };
41
+ } catch (err) {
42
+ if (err.statusCode === 404) {
38
43
  return {
39
- image: imageUrl
44
+ image: undefined
40
45
  };
41
- })
42
- .catch(function (err) {
43
- if (err.statusCode === 404) {
44
- return {
45
- image: undefined
46
- };
47
- }
46
+ }
48
47
 
49
- // ignore error, just resolve with no image url
50
- });
48
+ // ignore error, just resolve with no image url
49
+ }
51
50
  }
52
51
  }
53
52
 
@@ -74,7 +74,9 @@ module.exports = {
74
74
  && typeof storage.getStorage('images').saveRaw === 'function';
75
75
  },
76
76
  feature: {
77
- contentVisibility: labs.isSet('contentVisibility')
77
+ contentVisibility: labs.isSet('contentVisibility'),
78
+ emailCustomization: labs.isSet('emailCustomization'),
79
+ emailCustomizationAlpha: labs.isSet('emailCustomizationAlpha')
78
80
  }
79
81
  }, userOptions);
80
82
 
@@ -30,7 +30,12 @@ const Newsletter = ghostBookshelf.Model.extend({
30
30
  border_color: null,
31
31
  title_color: null,
32
32
  feedback_enabled: false,
33
- show_excerpt: false
33
+ show_excerpt: false,
34
+ button_corners: 'rounded',
35
+ button_style: 'fill',
36
+ title_font_weight: 'bold',
37
+ link_style: 'underline',
38
+ image_corners: 'square'
34
39
  };
35
40
  },
36
41
 
@@ -1,39 +1,9 @@
1
- const APIVersionCompatibilityService = require('./APIVersionCompatibilityService');
2
- const versionMismatchHandler = require('./mw-api-version-mismatch');
3
1
  const ghostVersion = require('@tryghost/version');
4
- const {GhostMailer} = require('../mail');
5
- const settingsService = require('../settings/settings-service');
6
- const models = require('../../models');
7
- const urlUtils = require('../../../shared/url-utils');
8
- const settingsCache = require('../../../shared/settings-cache');
9
-
10
- let serviceInstance;
11
-
12
- const init = () => {
13
- const ghostMailer = new GhostMailer();
14
-
15
- serviceInstance = new APIVersionCompatibilityService({
16
- UserModel: models.User,
17
- ApiKeyModel: models.ApiKey,
18
- settingsService: settingsService.getSettingsBREADServiceInstance(),
19
- sendEmail: (options) => {
20
- // NOTE: not using bind here because mockMailer is having trouble mocking bound methods
21
- return ghostMailer.send(options);
22
- },
23
- getSiteUrl: () => urlUtils.urlFor('home', true),
24
- getSiteTitle: () => settingsCache.get('title')
25
- });
26
- };
27
-
28
- module.exports.errorHandler = function apiVersionCompatibilityErrorHandler(err, req, res, next) {
29
- return versionMismatchHandler(serviceInstance)(err, req, res, next);
30
- };
31
2
 
32
3
  /**
33
4
  * Set Content-Version on the response, and add 'Accept-Version' to VARY as
34
5
  * it effects response caching
35
- * TODO: move the method to mw once back-compatibility with 4.x is sorted
36
- *
6
+ * * TODO: move the method to mw once back-compatibility with 4.x is sorted *
37
7
  * @param {import('express').Request} req
38
8
  * @param {import('express').Response} res
39
9
  * @param {import('express').NextFunction} next
@@ -47,5 +17,3 @@ module.exports.contentVersion = function apiVersionCompatibilityContentVersion(r
47
17
 
48
18
  module.exports.versionRewrites = require('./mw-version-rewrites');
49
19
  module.exports.legacyApiPathMatch = require('./legacy-api-path-match');
50
-
51
- module.exports.init = init;
@@ -4,7 +4,7 @@ module.exports = ({t, siteTitle, email, siteDomain, siteUrl, siteLogo, token, de
4
4
  <head>
5
5
  <meta name="viewport" content="width=device-width">
6
6
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
7
- <title>🔑 ${t('Your verification code for {{siteTitle}}', {siteTitle, interpolation: {escapeValue: false}})}</title>
7
+ <title>🔑 ${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>
8
8
  <style>
9
9
  /* -------------------------------------
10
10
  RESPONSIVE AND MOBILE FRIENDLY STYLES
@@ -107,7 +107,7 @@ module.exports = ({t, siteTitle, email, siteDomain, siteUrl, siteLogo, token, de
107
107
  <div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
108
108
 
109
109
  <!-- START CENTERED CONTAINER -->
110
- <span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">${t('Here\'s your code to login to {{siteTitle}}', {siteTitle, interpolation: {escapeValue: false}})}</span>
110
+ <span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">${t('Here\'s your code to login to {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</span>
111
111
  <table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
112
112
 
113
113
  <!-- START MAIN CONTENT AREA -->
@@ -120,7 +120,7 @@ module.exports = ({t, siteTitle, email, siteDomain, siteUrl, siteLogo, token, de
120
120
  <tr>
121
121
  <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;">
122
122
  <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: 600; line-height: 24px; margin: 0; margin-bottom: 15px; margin-top: 50px;">${t('Sign in verification')}</p>
123
- <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: 24px; margin-bottom: 32px;">${is2FARequired ? '' : t('You just tried to access your account from a new device.')} ${t('For security verification, enter the code below to sign in to {{siteTitle}}:', {siteTitle, interpolation: {escapeValue: false}})}</p>
123
+ <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: 24px; margin-bottom: 32px;">${is2FARequired ? '' : t('You just tried to access your account from a new device.')} ${t('For security verification, enter the code below to sign in to {siteTitle}:', {siteTitle, interpolation: {escapeValue: false}})}</p>
124
124
  </td>
125
125
  </tr>
126
126
  <tr>
@@ -0,0 +1,52 @@
1
+ const addressparser = require('nodemailer/lib/addressparser');
2
+
3
+ /**
4
+ * @typedef {Object} EmailAddress
5
+ * @property {string} address - The email address
6
+ * @property {string} [name] - Optional name associated with the email
7
+ */
8
+
9
+ module.exports = class EmailAddressParser {
10
+ /**
11
+ * Parse an email string into an EmailAddress object
12
+ * @param {string} email - Email string to parse
13
+ * @returns {EmailAddress|null} Parsed email or null if invalid
14
+ */
15
+ static parse(email) {
16
+ if (!email || typeof email !== 'string' || !email.length) {
17
+ return null;
18
+ }
19
+
20
+ const parsed = addressparser(email);
21
+
22
+ if (parsed.length !== 1) {
23
+ return null;
24
+ }
25
+ const first = parsed[0];
26
+
27
+ // Check first has a group property
28
+ if ('group' in first) {
29
+ // Unsupported format
30
+ return null;
31
+ }
32
+
33
+ return {
34
+ address: first.address,
35
+ name: first.name || undefined
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Convert an EmailAddress object to a string representation
41
+ * @param {EmailAddress} email - Email object to stringify
42
+ * @returns {string} String representation of the email
43
+ */
44
+ static stringify(email) {
45
+ if (!email.name) {
46
+ return email.address;
47
+ }
48
+
49
+ const escapedName = email.name.replace(/"/g, '\\"');
50
+ return `"${escapedName}" <${email.address}>`;
51
+ }
52
+ };
@@ -0,0 +1,13 @@
1
+ /* eslint-disable ghost/filenames/match-exported-class */
2
+
3
+ export interface EmailAddress {
4
+ address: string;
5
+ name?: string;
6
+ }
7
+
8
+ declare class EmailAddressParser {
9
+ static parse(email: string): EmailAddress | null;
10
+ static stringify(email: EmailAddress): string;
11
+ }
12
+
13
+ export default EmailAddressParser;
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.EmailAddressService = void 0;
7
+ /* eslint-disable ghost/filenames/match-exported-class */
8
+ const logging_1 = __importDefault(require("@tryghost/logging"));
9
+ const EmailAddressParser_js_1 = __importDefault(require("./EmailAddressParser.js"));
10
+ class EmailAddressService {
11
+ #getManagedEmailEnabled;
12
+ #getSendingDomain;
13
+ #getDefaultEmail;
14
+ #isValidEmailAddress;
15
+ #labs;
16
+ constructor(dependencies) {
17
+ this.#getManagedEmailEnabled = dependencies.getManagedEmailEnabled;
18
+ this.#getSendingDomain = dependencies.getSendingDomain;
19
+ this.#getDefaultEmail = dependencies.getDefaultEmail;
20
+ this.#isValidEmailAddress = dependencies.isValidEmailAddress;
21
+ this.#labs = dependencies.labs;
22
+ }
23
+ get sendingDomain() {
24
+ return this.#getSendingDomain();
25
+ }
26
+ get managedEmailEnabled() {
27
+ return this.#getManagedEmailEnabled();
28
+ }
29
+ get defaultFromEmail() {
30
+ return this.#getDefaultEmail();
31
+ }
32
+ getAddressFromString(from, replyTo) {
33
+ const parsedFrom = EmailAddressParser_js_1.default.parse(from);
34
+ const parsedReplyTo = replyTo ? EmailAddressParser_js_1.default.parse(replyTo) : undefined;
35
+ return this.getAddress({
36
+ from: parsedFrom ?? this.defaultFromEmail,
37
+ replyTo: parsedReplyTo ?? undefined
38
+ });
39
+ }
40
+ /**
41
+ * When sending an email, we should always ensure DMARC alignment.
42
+ * Because of that, we restrict which email addresses we send from. All emails should be either
43
+ * send from a configured domain (hostSettings.managedEmail.sendingDomains), or from the configured email address (mail.from).
44
+ *
45
+ * If we send an email from an email address that doesn't pass, we'll just default to the default email address,
46
+ * and instead add a replyTo email address from the requested from address.
47
+ */
48
+ getAddress(preferred) {
49
+ if (preferred.replyTo && !this.#isValidEmailAddress(preferred.replyTo.address)) {
50
+ // Remove invalid replyTo addresses
51
+ logging_1.default.error(`[EmailAddresses] Invalid replyTo address: ${preferred.replyTo.address}`);
52
+ preferred.replyTo = undefined;
53
+ }
54
+ // Validate the from address
55
+ if (!this.#isValidEmailAddress(preferred.from.address)) {
56
+ // Never allow an invalid email address
57
+ return {
58
+ from: this.defaultFromEmail,
59
+ replyTo: preferred.replyTo || undefined
60
+ };
61
+ }
62
+ if (!this.managedEmailEnabled) {
63
+ // Self hoster or legacy Ghost Pro
64
+ return preferred;
65
+ }
66
+ // Case: always allow the default from address
67
+ if (preferred.from.address === this.defaultFromEmail.address) {
68
+ if (!preferred.from.name) {
69
+ // Use the default sender name if it is missing
70
+ preferred.from.name = this.defaultFromEmail.name;
71
+ }
72
+ return preferred;
73
+ }
74
+ if (this.sendingDomain) {
75
+ // Check if FROM address is from the sending domain
76
+ if (preferred.from.address.endsWith(`@${this.sendingDomain}`)) {
77
+ return preferred;
78
+ }
79
+ // Invalid configuration: don't allow to send from this sending domain
80
+ logging_1.default.error(`[EmailAddresses] Invalid configuration: cannot send emails from ${preferred.from.address} when sending domain is ${this.sendingDomain}`);
81
+ }
82
+ // Only allow to send from the configured from address
83
+ const address = {
84
+ from: this.defaultFromEmail,
85
+ replyTo: preferred.replyTo || preferred.from
86
+ };
87
+ // Do allow to change the sender name if requested
88
+ if (preferred.from.name) {
89
+ address.from.name = preferred.from.name;
90
+ }
91
+ if (address.replyTo.address === address.from.address) {
92
+ return {
93
+ from: address.from
94
+ };
95
+ }
96
+ return address;
97
+ }
98
+ /**
99
+ * When changing any from or reply to addresses in the system, we need to validate them
100
+ */
101
+ validate(email, type) {
102
+ if (!this.#isValidEmailAddress(email)) {
103
+ // Never allow an invalid email address
104
+ return {
105
+ allowed: email === this.defaultFromEmail.address, // Localhost email noreply@127.0.0.1 is marked as invalid, but we should allow it
106
+ verificationEmailRequired: false,
107
+ reason: 'invalid'
108
+ };
109
+ }
110
+ if (!this.managedEmailEnabled) {
111
+ // Self hoster or legacy Ghost Pro
112
+ return {
113
+ allowed: true,
114
+ verificationEmailRequired: false // Self hosters don't need to verify email addresses
115
+ };
116
+ }
117
+ if (this.sendingDomain) {
118
+ // Only allow it if it ends with the sending domain
119
+ if (email.endsWith(`@${this.sendingDomain}`)) {
120
+ return {
121
+ allowed: true,
122
+ verificationEmailRequired: false
123
+ };
124
+ }
125
+ // Use same restrictions as one without a sending domain for other addresses
126
+ }
127
+ // Only allow to edit the replyTo address, with verification
128
+ if (type === 'replyTo') {
129
+ return {
130
+ allowed: true,
131
+ verificationEmailRequired: email !== this.defaultFromEmail.address
132
+ };
133
+ }
134
+ // Not allowed to change from
135
+ return {
136
+ allowed: email === this.defaultFromEmail.address,
137
+ verificationEmailRequired: false,
138
+ reason: 'not allowed'
139
+ };
140
+ }
141
+ }
142
+ exports.EmailAddressService = EmailAddressService;