ghost 5.37.0 → 5.38.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 (154) hide show
  1. package/components/{tryghost-adapter-cache-memory-ttl-5.37.0.tgz → tryghost-adapter-cache-memory-ttl-5.38.0.tgz} +0 -0
  2. package/components/tryghost-adapter-cache-redis-5.38.0.tgz +0 -0
  3. package/components/{tryghost-adapter-manager-5.37.0.tgz → tryghost-adapter-manager-5.38.0.tgz} +0 -0
  4. package/components/{tryghost-api-framework-5.37.0.tgz → tryghost-api-framework-5.38.0.tgz} +0 -0
  5. package/components/{tryghost-api-version-compatibility-service-5.37.0.tgz → tryghost-api-version-compatibility-service-5.38.0.tgz} +0 -0
  6. package/components/tryghost-audience-feedback-5.38.0.tgz +0 -0
  7. package/components/tryghost-bootstrap-socket-5.38.0.tgz +0 -0
  8. package/components/{tryghost-constants-5.37.0.tgz → tryghost-constants-5.38.0.tgz} +0 -0
  9. package/components/tryghost-custom-theme-settings-service-5.38.0.tgz +0 -0
  10. package/components/{tryghost-data-generator-5.37.0.tgz → tryghost-data-generator-5.38.0.tgz} +0 -0
  11. package/components/tryghost-domain-events-5.38.0.tgz +0 -0
  12. package/components/tryghost-dynamic-routing-events-5.38.0.tgz +0 -0
  13. package/components/tryghost-email-analytics-provider-mailgun-5.38.0.tgz +0 -0
  14. package/components/{tryghost-email-analytics-service-5.37.0.tgz → tryghost-email-analytics-service-5.38.0.tgz} +0 -0
  15. package/components/tryghost-email-content-generator-5.38.0.tgz +0 -0
  16. package/components/tryghost-email-events-5.38.0.tgz +0 -0
  17. package/components/tryghost-email-service-5.38.0.tgz +0 -0
  18. package/components/{tryghost-email-suppression-list-5.37.0.tgz → tryghost-email-suppression-list-5.38.0.tgz} +0 -0
  19. package/components/tryghost-event-aware-cache-wrapper-5.38.0.tgz +0 -0
  20. package/components/{tryghost-express-dynamic-redirects-5.37.0.tgz → tryghost-express-dynamic-redirects-5.38.0.tgz} +0 -0
  21. package/components/tryghost-external-media-inliner-5.38.0.tgz +0 -0
  22. package/components/tryghost-extract-api-key-5.38.0.tgz +0 -0
  23. package/components/tryghost-html-to-plaintext-5.38.0.tgz +0 -0
  24. package/components/tryghost-i18n-5.38.0.tgz +0 -0
  25. package/components/{tryghost-importer-handler-content-files-5.37.0.tgz → tryghost-importer-handler-content-files-5.38.0.tgz} +0 -0
  26. package/components/tryghost-importer-revue-5.38.0.tgz +0 -0
  27. package/components/tryghost-job-manager-5.38.0.tgz +0 -0
  28. package/components/tryghost-link-redirects-5.38.0.tgz +0 -0
  29. package/components/tryghost-link-replacer-5.38.0.tgz +0 -0
  30. package/components/{tryghost-link-tracking-5.37.0.tgz → tryghost-link-tracking-5.38.0.tgz} +0 -0
  31. package/components/{tryghost-magic-link-5.37.0.tgz → tryghost-magic-link-5.38.0.tgz} +0 -0
  32. package/components/tryghost-mailgun-client-5.38.0.tgz +0 -0
  33. package/components/tryghost-member-attribution-5.38.0.tgz +0 -0
  34. package/components/tryghost-member-events-5.38.0.tgz +0 -0
  35. package/components/tryghost-members-api-5.38.0.tgz +0 -0
  36. package/components/tryghost-members-csv-5.38.0.tgz +0 -0
  37. package/components/{tryghost-members-events-service-5.37.0.tgz → tryghost-members-events-service-5.38.0.tgz} +0 -0
  38. package/components/{tryghost-members-importer-5.37.0.tgz → tryghost-members-importer-5.38.0.tgz} +0 -0
  39. package/components/tryghost-members-offers-5.38.0.tgz +0 -0
  40. package/components/tryghost-members-payments-5.38.0.tgz +0 -0
  41. package/components/tryghost-members-ssr-5.38.0.tgz +0 -0
  42. package/components/{tryghost-members-stripe-service-5.37.0.tgz → tryghost-members-stripe-service-5.38.0.tgz} +0 -0
  43. package/components/tryghost-milestones-5.38.0.tgz +0 -0
  44. package/components/tryghost-minifier-5.38.0.tgz +0 -0
  45. package/components/tryghost-mw-api-version-mismatch-5.38.0.tgz +0 -0
  46. package/components/{tryghost-mw-cache-control-5.37.0.tgz → tryghost-mw-cache-control-5.38.0.tgz} +0 -0
  47. package/components/tryghost-mw-error-handler-5.38.0.tgz +0 -0
  48. package/components/tryghost-mw-session-from-token-5.38.0.tgz +0 -0
  49. package/components/tryghost-mw-update-user-last-seen-5.38.0.tgz +0 -0
  50. package/components/tryghost-mw-version-match-5.38.0.tgz +0 -0
  51. package/components/tryghost-mw-vhost-5.38.0.tgz +0 -0
  52. package/components/tryghost-oembed-service-5.38.0.tgz +0 -0
  53. package/components/tryghost-package-json-5.38.0.tgz +0 -0
  54. package/components/{tryghost-referrers-5.37.0.tgz → tryghost-referrers-5.38.0.tgz} +0 -0
  55. package/components/tryghost-security-5.38.0.tgz +0 -0
  56. package/components/tryghost-session-service-5.38.0.tgz +0 -0
  57. package/components/tryghost-settings-path-manager-5.38.0.tgz +0 -0
  58. package/components/tryghost-slack-notifications-5.38.0.tgz +0 -0
  59. package/components/tryghost-staff-service-5.38.0.tgz +0 -0
  60. package/components/tryghost-stats-service-5.38.0.tgz +0 -0
  61. package/components/tryghost-tiers-5.38.0.tgz +0 -0
  62. package/components/{tryghost-update-check-service-5.37.0.tgz → tryghost-update-check-service-5.38.0.tgz} +0 -0
  63. package/components/tryghost-verification-trigger-5.38.0.tgz +0 -0
  64. package/components/{tryghost-version-notifications-data-service-5.37.0.tgz → tryghost-version-notifications-data-service-5.38.0.tgz} +0 -0
  65. package/components/tryghost-webmentions-5.38.0.tgz +0 -0
  66. package/core/boot.js +11 -4
  67. package/core/built/admin/assets/{chunk.143.27cd10a38f877e715b35.js → chunk.143.c6802c882a911797ce4f.js} +6 -6
  68. package/core/built/admin/assets/{chunk.178.dd6cf17fb0986acf19d6.js → chunk.178.09faefd4027fcba4113d.js} +4 -4
  69. package/core/built/admin/assets/{chunk.652.bb618bc5abf23bed4e87.js → chunk.220.9ca2950240aba3fced21.js} +1836 -1774
  70. package/core/built/admin/assets/{chunk.79.4a959c324df25480b90e.js → chunk.79.acb7dd01e1c785f4920c.js} +12 -11
  71. package/core/built/admin/assets/{ghost-2948791640be026b987b88f89034bc85.js → ghost-35103ff053c43f1dfa7f35821c3c2412.js} +29 -29
  72. package/core/built/admin/assets/{ghost-efbe4dcc249d119a955b038aae5c980d.css → ghost-a9307c9cfe26a4bc621e02cd3bae421a.css} +1 -1
  73. package/core/built/admin/assets/{ghost-dark-6ea4b338f17a43c204b7c1e207b90cd7.css → ghost-dark-f309cf445255344e4861a95ecb8f1920.css} +1 -1
  74. package/core/built/admin/assets/vendor-b982e3bf1020bff77b2a3c44d5f59e55.js +269 -269
  75. package/core/built/admin/index.html +5 -5
  76. package/core/frontend/helpers/ghost_head.js +4 -1
  77. package/core/frontend/services/routing/StaticPagesRouter.js +1 -1
  78. package/core/frontend/services/sitemap/base-generator.js +5 -1
  79. package/core/server/adapters/storage/LocalImagesStorage.js +1 -1
  80. package/core/server/api/endpoints/email-previews.js +2 -43
  81. package/core/server/api/endpoints/emails.js +1 -22
  82. package/core/server/api/endpoints/utils/serializers/output/mappers/emails.js +14 -8
  83. package/core/server/data/importer/import-manager.js +8 -1
  84. package/core/server/data/migrations/versions/4.9/05-fix-missed-mobiledoc-url-transforms.js +1 -1
  85. package/core/server/lib/common/events.js +16 -23
  86. package/core/server/models/base/plugins/relations.js +5 -3
  87. package/core/server/models/index.js +5 -0
  88. package/core/server/services/comments/emails.js +2 -2
  89. package/core/server/services/email-service/wrapper.js +2 -0
  90. package/core/server/services/link-tracking/LinkClickRepository.js +1 -1
  91. package/core/server/services/media-inliner/service.js +49 -3
  92. package/core/server/services/mentions/service.js +6 -1
  93. package/core/server/services/posts/posts-service.js +3 -14
  94. package/core/server/services/staff/index.js +2 -0
  95. package/core/server/services/url/Urls.js +10 -2
  96. package/core/shared/labs.js +0 -1
  97. package/package.json +138 -138
  98. package/yarn.lock +267 -259
  99. package/components/tryghost-adapter-cache-redis-5.37.0.tgz +0 -0
  100. package/components/tryghost-audience-feedback-5.37.0.tgz +0 -0
  101. package/components/tryghost-bootstrap-socket-5.37.0.tgz +0 -0
  102. package/components/tryghost-custom-theme-settings-service-5.37.0.tgz +0 -0
  103. package/components/tryghost-domain-events-5.37.0.tgz +0 -0
  104. package/components/tryghost-dynamic-routing-events-5.37.0.tgz +0 -0
  105. package/components/tryghost-email-analytics-provider-mailgun-5.37.0.tgz +0 -0
  106. package/components/tryghost-email-content-generator-5.37.0.tgz +0 -0
  107. package/components/tryghost-email-events-5.37.0.tgz +0 -0
  108. package/components/tryghost-email-service-5.37.0.tgz +0 -0
  109. package/components/tryghost-event-aware-cache-wrapper-5.37.0.tgz +0 -0
  110. package/components/tryghost-external-media-inliner-5.37.0.tgz +0 -0
  111. package/components/tryghost-extract-api-key-5.37.0.tgz +0 -0
  112. package/components/tryghost-html-to-plaintext-5.37.0.tgz +0 -0
  113. package/components/tryghost-i18n-5.37.0.tgz +0 -0
  114. package/components/tryghost-importer-revue-5.37.0.tgz +0 -0
  115. package/components/tryghost-job-manager-5.37.0.tgz +0 -0
  116. package/components/tryghost-link-redirects-5.37.0.tgz +0 -0
  117. package/components/tryghost-link-replacer-5.37.0.tgz +0 -0
  118. package/components/tryghost-mailgun-client-5.37.0.tgz +0 -0
  119. package/components/tryghost-member-attribution-5.37.0.tgz +0 -0
  120. package/components/tryghost-member-events-5.37.0.tgz +0 -0
  121. package/components/tryghost-members-api-5.37.0.tgz +0 -0
  122. package/components/tryghost-members-csv-5.37.0.tgz +0 -0
  123. package/components/tryghost-members-offers-5.37.0.tgz +0 -0
  124. package/components/tryghost-members-payments-5.37.0.tgz +0 -0
  125. package/components/tryghost-members-ssr-5.37.0.tgz +0 -0
  126. package/components/tryghost-milestones-5.37.0.tgz +0 -0
  127. package/components/tryghost-minifier-5.37.0.tgz +0 -0
  128. package/components/tryghost-mw-api-version-mismatch-5.37.0.tgz +0 -0
  129. package/components/tryghost-mw-error-handler-5.37.0.tgz +0 -0
  130. package/components/tryghost-mw-session-from-token-5.37.0.tgz +0 -0
  131. package/components/tryghost-mw-update-user-last-seen-5.37.0.tgz +0 -0
  132. package/components/tryghost-mw-version-match-5.37.0.tgz +0 -0
  133. package/components/tryghost-mw-vhost-5.37.0.tgz +0 -0
  134. package/components/tryghost-oembed-service-5.37.0.tgz +0 -0
  135. package/components/tryghost-package-json-5.37.0.tgz +0 -0
  136. package/components/tryghost-security-5.37.0.tgz +0 -0
  137. package/components/tryghost-session-service-5.37.0.tgz +0 -0
  138. package/components/tryghost-settings-path-manager-5.37.0.tgz +0 -0
  139. package/components/tryghost-slack-notifications-5.37.0.tgz +0 -0
  140. package/components/tryghost-staff-service-5.37.0.tgz +0 -0
  141. package/components/tryghost-stats-service-5.37.0.tgz +0 -0
  142. package/components/tryghost-tiers-5.37.0.tgz +0 -0
  143. package/components/tryghost-verification-trigger-5.37.0.tgz +0 -0
  144. package/components/tryghost-webmentions-5.37.0.tgz +0 -0
  145. package/core/server/services/bulk-email/bulk-email-processor.js +0 -289
  146. package/core/server/services/bulk-email/index.js +0 -1
  147. package/core/server/services/mega/email-preview.js +0 -54
  148. package/core/server/services/mega/feedback-buttons.js +0 -66
  149. package/core/server/services/mega/index.js +0 -14
  150. package/core/server/services/mega/mega.js +0 -626
  151. package/core/server/services/mega/post-email-serializer.js +0 -559
  152. package/core/server/services/mega/segment-parser.js +0 -20
  153. package/core/server/services/mega/template.js +0 -1319
  154. /package/core/built/admin/assets/{chunk.652.bb618bc5abf23bed4e87.js.LICENSE.txt → chunk.220.9ca2950240aba3fced21.js.LICENSE.txt} +0 -0
@@ -1,559 +0,0 @@
1
- const _ = require('lodash');
2
- const template = require('./template');
3
- const settingsCache = require('../../../shared/settings-cache');
4
- const urlUtils = require('../../../shared/url-utils');
5
- const moment = require('moment-timezone');
6
- const api = require('../../api').endpoints;
7
- const apiFramework = require('@tryghost/api-framework');
8
- const {URL} = require('url');
9
- const mobiledocLib = require('../../lib/mobiledoc');
10
- const lexicalLib = require('../../lib/lexical');
11
- const htmlToPlaintext = require('@tryghost/html-to-plaintext');
12
- const membersService = require('../members');
13
- const {isUnsplashImage} = require('@tryghost/kg-default-cards/lib/utils');
14
- const {textColorForBackgroundColor, darkenToContrastThreshold} = require('@tryghost/color-utils');
15
- const logging = require('@tryghost/logging');
16
- const urlService = require('../../services/url');
17
- const linkReplacer = require('@tryghost/link-replacer');
18
- const linkTracking = require('../link-tracking');
19
- const memberAttribution = require('../member-attribution');
20
- const feedbackButtons = require('./feedback-buttons');
21
- const labs = require('../../../shared/labs');
22
- const storageUtils = require('../../adapters/storage/utils');
23
-
24
- const ALLOWED_REPLACEMENTS = ['first_name', 'uuid'];
25
-
26
- const PostEmailSerializer = {
27
-
28
- // Format a full html document ready for email by inlining CSS, adjusting links,
29
- // and performing any client-specific fixes
30
- formatHtmlForEmail(html) {
31
- const juiceOptions = {inlinePseudoElements: true};
32
-
33
- const juice = require('juice');
34
- let juicedHtml = juice(html, juiceOptions);
35
-
36
- // convert juiced HTML to a DOM-like interface for further manipulation
37
- // happens after inlining of CSS so we can change element types without worrying about styling
38
-
39
- const cheerio = require('cheerio');
40
- const _cheerio = cheerio.load(juicedHtml);
41
-
42
- // force all links to open in new tab
43
- _cheerio('a').attr('target', '_blank');
44
- // convert figure and figcaption to div so that Outlook applies margins
45
- _cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
46
-
47
- juicedHtml = _cheerio.html();
48
-
49
- // Fix any unsupported chars in Outlook
50
- juicedHtml = juicedHtml.replace(/'/g, ''');
51
- juicedHtml = juicedHtml.replace(/→/g, '→');
52
- juicedHtml = juicedHtml.replace(/–/g, '–');
53
- juicedHtml = juicedHtml.replace(/“/g, '“');
54
- juicedHtml = juicedHtml.replace(/”/g, '”');
55
- return juicedHtml;
56
- },
57
-
58
- getSite() {
59
- const publicSettings = settingsCache.getPublic();
60
- return Object.assign({}, publicSettings, {
61
- url: urlUtils.urlFor('home', true),
62
- iconUrl: publicSettings.icon ? urlUtils.urlFor('image', {image: publicSettings.icon}, true) : null
63
- });
64
- },
65
-
66
- /**
67
- * createUnsubscribeUrl
68
- *
69
- * Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
70
- * In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
71
- *
72
- * @param {string} uuid post uuid
73
- * @param {Object} [options]
74
- * @param {string} [options.newsletterUuid] newsletter uuid
75
- * @param {boolean} [options.comments] Unsubscribe from comment emails
76
- */
77
- createUnsubscribeUrl(uuid, options = {}) {
78
- const siteUrl = urlUtils.getSiteUrl();
79
- const unsubscribeUrl = new URL(siteUrl);
80
- unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
81
- if (uuid) {
82
- unsubscribeUrl.searchParams.set('uuid', uuid);
83
- } else {
84
- unsubscribeUrl.searchParams.set('preview', '1');
85
- }
86
- if (options.newsletterUuid) {
87
- unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid);
88
- }
89
- if (options.comments) {
90
- unsubscribeUrl.searchParams.set('comments', '1');
91
- }
92
-
93
- return unsubscribeUrl.href;
94
- },
95
-
96
- /**
97
- * createPostSignupUrl
98
- *
99
- * Takes a post object. Returns the url that should be used to signup from newsletter
100
- *
101
- * @param {Object} post post object
102
- */
103
- createPostSignupUrl(post) {
104
- let url = urlService.getUrlByResourceId(post.id, {absolute: true});
105
-
106
- // For email-only posts, use site url as base
107
- if (post.status !== 'published' && url.match(/\/404\//)) {
108
- url = urlUtils.getSiteUrl();
109
- }
110
-
111
- const signupUrl = new URL(url);
112
- signupUrl.hash = `/portal/signup`;
113
-
114
- return signupUrl.href;
115
- },
116
-
117
- /**
118
- * replaceFeedbackLinks
119
- *
120
- * Replace the button template links with real links
121
- *
122
- * @param {string} html
123
- * @param {string} postId (will be url encoded)
124
- * @param {string} memberUuid member uuid to use in the URL (will be url encoded)
125
- */
126
- replaceFeedbackLinks(html, postId, memberUuid) {
127
- return feedbackButtons.generateLinks(postId, memberUuid, html);
128
- },
129
-
130
- // NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
131
- async serializePostModel(model) {
132
- // fetch mobiledoc/lexical rather than html and plaintext so we can render email-specific contents
133
- const frame = {options: {context: {user: true}, formats: 'mobiledoc,lexical'}};
134
- const docName = 'posts';
135
-
136
- await apiFramework
137
- .serializers
138
- .handle
139
- .output(model, {docName: docName, method: 'read'}, api.serializers.output, frame);
140
-
141
- return frame.response[docName][0];
142
- },
143
-
144
- // removes %% wrappers from unknown replacement strings in email content
145
- normalizeReplacementStrings(email) {
146
- // we don't want to modify the email object in-place
147
- const emailContent = _.pick(email, ['html', 'plaintext']);
148
-
149
- const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
150
- const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
151
-
152
- ['html', 'plaintext'].forEach((format) => {
153
- emailContent[format] = emailContent[format].replace(EMAIL_REPLACEMENT_REGEX, (replacementMatch, replacementStr) => {
154
- const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
155
-
156
- if (match) {
157
- const {recipientProperty} = match.groups;
158
-
159
- if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
160
- // keeps wrapping %% for later replacement with real data
161
- return replacementMatch;
162
- }
163
- }
164
-
165
- // removes %% so output matches user supplied content
166
- return replacementStr;
167
- });
168
- });
169
-
170
- return emailContent;
171
- },
172
-
173
- /**
174
- * Parses email content and extracts an array of replacements with desired fallbacks
175
- *
176
- * @param {Object} email
177
- * @param {string} email.html
178
- * @param {string} email.plaintext
179
- *
180
- * @returns {Object[]} replacements
181
- */
182
- parseReplacements(email) {
183
- const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
184
- const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
185
-
186
- function escapeRegExp(string) {
187
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
188
- }
189
-
190
- const replacements = [];
191
-
192
- ['html', 'plaintext'].forEach((format) => {
193
- let result;
194
- while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) {
195
- const [replacementMatch, replacementStr] = result;
196
-
197
- // Did we already found this match and added it to the replacements array?
198
- if (replacements.find(r => r.match === replacementMatch && r.format === format)) {
199
- continue;
200
- }
201
- const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
202
-
203
- if (match) {
204
- const {recipientProperty, fallback} = match.groups;
205
-
206
- if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
207
- const id = `replacement_${replacements.length + 1}`;
208
-
209
- replacements.push({
210
- format,
211
- id,
212
- match: replacementMatch,
213
- regexp: new RegExp(escapeRegExp(replacementMatch), 'g'),
214
- recipientProperty: `member_${recipientProperty}`,
215
- fallback
216
- });
217
- }
218
- }
219
- }
220
- });
221
-
222
- return replacements;
223
- },
224
-
225
- async getTemplateSettings(newsletter) {
226
- const accentColor = settingsCache.get('accent_color');
227
- const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
228
- const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
229
-
230
- const templateSettings = {
231
- headerImage: newsletter.get('header_image'),
232
- showHeaderIcon: newsletter.get('show_header_icon') && settingsCache.get('icon'),
233
- showHeaderTitle: newsletter.get('show_header_title'),
234
- showFeatureImage: newsletter.get('show_feature_image'),
235
- titleFontCategory: newsletter.get('title_font_category'),
236
- titleAlignment: newsletter.get('title_alignment'),
237
- bodyFontCategory: newsletter.get('body_font_category'),
238
- showBadge: newsletter.get('show_badge'),
239
- feedbackEnabled: newsletter.get('feedback_enabled') && labs.isSet('audienceFeedback'),
240
- footerContent: newsletter.get('footer_content'),
241
- showHeaderName: newsletter.get('show_header_name'),
242
- accentColor,
243
- adjustedAccentColor,
244
- adjustedAccentContrastColor
245
- };
246
-
247
- if (templateSettings.headerImage) {
248
- if (isUnsplashImage(templateSettings.headerImage)) {
249
- // Unsplash images have a minimum size so assuming 1200px is safe
250
- const unsplashUrl = new URL(templateSettings.headerImage);
251
- unsplashUrl.searchParams.set('w', '1200');
252
-
253
- templateSettings.headerImage = unsplashUrl.href;
254
- templateSettings.headerImageWidth = 600;
255
- } else {
256
- const {imageSize} = require('../../lib/image');
257
- try {
258
- const size = await imageSize.getImageSizeFromUrl(templateSettings.headerImage);
259
-
260
- if (size.width >= 600) {
261
- // keep original image, just set a fixed width
262
- templateSettings.headerImageWidth = 600;
263
- }
264
-
265
- if (storageUtils.isLocalImage(templateSettings.headerImage)) {
266
- // we can safely request a 1200px image - Ghost will serve the original if it's smaller
267
- templateSettings.headerImage = templateSettings.headerImage.replace(/\/content\/images\//, '/content/images/size/w1200/');
268
- }
269
- } catch (err) {
270
- // log and proceed. Using original header image without fixed width isn't fatal.
271
- logging.error(err);
272
- }
273
- }
274
- }
275
-
276
- return templateSettings;
277
- },
278
-
279
- async serialize(postModel, newsletter, options = {isBrowserPreview: false, isTestEmail: false}) {
280
- const post = await this.serializePostModel(postModel);
281
-
282
- const timezone = settingsCache.get('timezone');
283
- const momentDate = post.published_at ? moment(post.published_at) : moment();
284
- post.published_at = momentDate.tz(timezone).format('DD MMM YYYY');
285
-
286
- if (post.authors) {
287
- if (post.authors.length <= 2) {
288
- post.authors = post.authors.map(author => author.name).join(' & ');
289
- } else if (post.authors.length > 2) {
290
- post.authors = `${post.authors[0].name} & ${post.authors.length - 1} others`;
291
- }
292
- }
293
-
294
- if (post.posts_meta) {
295
- post.email_subject = post.posts_meta.email_subject;
296
- }
297
-
298
- // we use post.excerpt as a hidden piece of text that is picked up by some email
299
- // clients as a "preview" when listing emails. Our current plaintext/excerpt
300
- // generation outputs links as "Link [https://url/]" which isn't desired in the preview
301
- if (!post.custom_excerpt && post.excerpt) {
302
- post.excerpt = post.excerpt.replace(/\s\[http(.*?)\]/g, '');
303
- }
304
-
305
- if (post.lexical) {
306
- post.html = lexicalLib.render(
307
- post.lexical, {target: 'email', postUrl: post.url}
308
- );
309
- } else {
310
- post.html = mobiledocLib.mobiledocHtmlRenderer.render(
311
- JSON.parse(post.mobiledoc), {target: 'email', postUrl: post.url}
312
- );
313
- }
314
-
315
- // perform any email specific adjustments to the HTML render output.
316
- // body wrapper is required so we can get proper top-level selections
317
- const cheerio = require('cheerio');
318
- const _cheerio = cheerio.load(`<body>${post.html}</body>`);
319
- // remove leading/trailing HRs
320
- _cheerio(`
321
- body > hr:first-child,
322
- body > hr:last-child,
323
- body > div:first-child > hr:first-child,
324
- body > div:last-child > hr:last-child
325
- `).remove();
326
- post.html = _cheerio('body').html(); // () (added this comment because of a bug in the syntax highlighter in VSCode)
327
-
328
- // Note: we don't need to do link replacements on the plaintext here
329
- // because the plaintext will get recalculated on the updated post html (which already includes link replacements) in renderEmailForSegment
330
- post.plaintext = htmlToPlaintext.email(post.html);
331
-
332
- // Outlook will render feature images at full-size breaking the layout.
333
- // Content images fix this by rendering max 600px images - do the same for feature image here
334
- if (post.feature_image) {
335
- if (isUnsplashImage(post.feature_image)) {
336
- // Unsplash images have a minimum size so assuming 1200px is safe
337
- const unsplashUrl = new URL(post.feature_image);
338
- unsplashUrl.searchParams.set('w', '1200');
339
-
340
- post.feature_image = unsplashUrl.href;
341
- post.feature_image_width = 600;
342
- } else {
343
- const {imageSize} = require('../../lib/image');
344
- try {
345
- const size = await imageSize.getImageSizeFromUrl(post.feature_image);
346
-
347
- if (size.width >= 600) {
348
- // keep original image, just set a fixed width
349
- post.feature_image_width = 600;
350
- }
351
-
352
- if (storageUtils.isLocalImage(post.feature_image)) {
353
- // we can safely request a 1200px image - Ghost will serve the original if it's smaller
354
- post.feature_image = post.feature_image.replace(/\/content\/images\//, '/content/images/size/w1200/');
355
- }
356
- } catch (err) {
357
- // log and proceed. Using original feature_image without fixed width isn't fatal.
358
- logging.error(err);
359
- }
360
- }
361
- }
362
-
363
- const templateSettings = await this.getTemplateSettings(newsletter);
364
-
365
- const render = template;
366
-
367
- let htmlTemplate = render({post, site: this.getSite(), templateSettings, newsletter: newsletter.toJSON()});
368
-
369
- // The plaintext version that is returned here is actually never really used for sending because we'll use htmlToPlaintext again later
370
- let result = {
371
- html: this.formatHtmlForEmail(htmlTemplate),
372
- plaintext: post.plaintext
373
- };
374
-
375
- /**
376
- * If a part of the email is members-only and the post is paid-only, add a paywall:
377
- * - Just before sending the email, we'll hide the paywall or paid content depending on the member segment it is sent to.
378
- * - We already need to do URL-replacement on the HTML here
379
- * - Link replacement cannot happen later because renderEmailForSegment is called multiple times for a single email (which would result in duplicate redirects)
380
- */
381
- const isPaidPost = post.visibility === 'paid' || post.visibility === 'tiers';
382
-
383
- const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
384
- if (paywallIndex !== -1 && isPaidPost) {
385
- const postContentEndIdx = result.html.indexOf('<!-- POST CONTENT END -->');
386
-
387
- if (postContentEndIdx !== -1) {
388
- const paywallHTML = '<!-- PAYWALL -->' + this.renderPaywallCTA(post);
389
-
390
- // Append it just before the end of the post content
391
- result.html = result.html.slice(0, postContentEndIdx) + paywallHTML + result.html.slice(postContentEndIdx);
392
- }
393
- }
394
-
395
- // Now replace the links in the HTML version
396
- if (!options.isBrowserPreview && !options.isTestEmail && settingsCache.get('email_track_clicks')) {
397
- result.html = await linkReplacer.replace(result.html, async (url) => {
398
- // Add newsletter source attribution
399
- const isSite = urlUtils.isSiteUrl(url);
400
-
401
- if (isSite) {
402
- // Add newsletter name as ref to the URL
403
- url = memberAttribution.outboundLinkTagger.addToUrl(url, newsletter);
404
-
405
- // Only add post attribution to our own site (because external sites could/should not process this information)
406
- url = memberAttribution.service.addPostAttributionTracking(url, post);
407
- } else {
408
- // Add email source attribution without the newsletter name
409
- url = memberAttribution.outboundLinkTagger.addToUrl(url);
410
- }
411
-
412
- // Add link click tracking
413
- url = await linkTracking.service.addTrackingToUrl(url, post, '--uuid--');
414
-
415
- // We need to convert to a string at this point, because we need invalid string characters in the URL
416
- const str = url.toString().replace(/--uuid--/g, '%%{uuid}%%');
417
- return str;
418
- });
419
- }
420
-
421
- // Add buttons
422
- if (labs.isSet('audienceFeedback')) {
423
- // create unique urls for every recipient (for example, for feedback buttons)
424
- // Note, we need to use a different member uuid in the links because `%%{uuid}%%` would get escaped by the URL object when set as a search param
425
- const urlSafeToken = '--' + new Date().getTime() + 'url-safe-uuid--';
426
- result.html = this.replaceFeedbackLinks(result.html, post.id, urlSafeToken).replace(new RegExp(urlSafeToken, 'g'), '%%{uuid}%%');
427
- }
428
-
429
- // Clean up any unknown replacements strings to get our final content
430
- const {html, plaintext} = this.normalizeReplacementStrings(result);
431
- const data = {
432
- subject: post.email_subject || post.title,
433
- html,
434
- plaintext
435
- };
436
-
437
- // Add post for checking access in renderEmailForSegment (only for previews)
438
- data.post = post;
439
- return data;
440
- },
441
-
442
- /**
443
- * renderPaywallCTA
444
- *
445
- * outputs html for rendering paywall CTA in newsletter
446
- *
447
- * @param {Object} post Post Object
448
- */
449
- renderPaywallCTA(post) {
450
- const accentColor = settingsCache.get('accent_color');
451
- const siteTitle = settingsCache.get('title') || 'Ghost';
452
- const signupUrl = this.createPostSignupUrl(post);
453
-
454
- return `<div class="align-center" style="text-align: center;">
455
- <hr
456
- style="position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;">
457
- <h2
458
- style="margin-top: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 1.11em; font-weight: 700; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 26px;">
459
- Subscribe to <span style="white-space: nowrap; font-size: 26px !important;">continue reading.</span></h2>
460
- <p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 440px;">Become a paid member of ${siteTitle} to get access to all
461
- <span style="white-space: nowrap;">subscriber-only content.</span></p>
462
- <div class="btn btn-accent" style="box-sizing: border-box; width: 100%; display: table;">
463
- <table border="0" cellspacing="0" cellpadding="0" align="center"
464
- style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
465
- <tbody>
466
- <tr>
467
- <td align="center"
468
- style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; text-align: center; border-radius: 5px;"
469
- valign="top" bgcolor="${accentColor}">
470
- <a href="${signupUrl}"
471
- style="overflow-wrap: anywhere; border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; background-color: ${accentColor}; border-color: ${accentColor}; color: #FFFFFF;"
472
- target="_blank">Subscribe
473
- </a>
474
- </td>
475
- </tr>
476
- </tbody>
477
- </table>
478
- </div>
479
- <p style="margin: 0 0 1.5em 0; line-height: 1.6em;"></p>
480
- </div>`;
481
- },
482
-
483
- renderEmailForSegment(email, memberSegment) {
484
- const cheerio = require('cheerio');
485
-
486
- const result = {...email};
487
-
488
- // Note about link tracking:
489
- // Don't add new HTML in here, but add it in the serialize method and surround it with the required HTML comments or attributes
490
- // This is because we can't replace links at this point (this is executed multiple times, once per batch and we don't want to generate duplicate links for the same email)
491
-
492
- // Remove the paywall or members-only content based on the current member segment
493
- const startMembersOnlyContent = (result.html || '').indexOf('<!--members-only-->');
494
- const startPaywall = result.html.indexOf('<!-- PAYWALL -->');
495
- let endPost = result.html.indexOf('<!-- POST CONTENT END -->');
496
-
497
- if (endPost === -1) {
498
- // Default to the end of the HTML (shouldn't happen, but just in case if we have members-only content that should get removed)
499
- endPost = result.html.length;
500
- }
501
-
502
- // We support the cases where there is no <!--members-only--> but there is a paywall (in case of bugs)
503
- // We also support the case where there is no <!-- PAYWALL --> but there is a <!--members-only--> (in case of bugs)
504
- if (startMembersOnlyContent !== -1 || startPaywall !== -1) {
505
- // By default remove the paywall if no memberSegment is passed
506
- let memberHasAccess = true;
507
-
508
- if (memberSegment && result.post) {
509
- let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
510
- const postVisiblity = result.post.visibility;
511
-
512
- // For newsletter paywall, specific tiers visibility is considered on par to paid tiers
513
- result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
514
-
515
- memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
516
- }
517
-
518
- if (!memberHasAccess) {
519
- if (startMembersOnlyContent !== -1) {
520
- // Remove the members-only content, but keep the paywall (if there is a paywall)
521
- result.html = result.html.slice(0, startMembersOnlyContent) + result.html.slice(startPaywall === -1 ? endPost : startPaywall);
522
- }
523
- } else {
524
- if (startPaywall !== -1) {
525
- // Remove the paywall
526
- result.html = result.html.slice(0, startPaywall) + result.html.slice(endPost);
527
- }
528
- }
529
- }
530
-
531
- const $ = cheerio.load(result.html);
532
-
533
- $('[data-gh-segment]').get().forEach((node) => {
534
- if (node.attribs['data-gh-segment'] !== memberSegment) { //TODO: replace with NQL interpretation
535
- $(node).remove();
536
- } else {
537
- // Getting rid of the attribute for a cleaner html output
538
- $(node).removeAttr('data-gh-segment');
539
- }
540
- });
541
-
542
- result.html = this.formatHtmlForEmail($.html());
543
- result.plaintext = htmlToPlaintext.email(result.html);
544
- delete result.post;
545
-
546
- return result;
547
- }
548
- };
549
-
550
- module.exports = {
551
- serialize: PostEmailSerializer.serialize.bind(PostEmailSerializer),
552
- createUnsubscribeUrl: PostEmailSerializer.createUnsubscribeUrl.bind(PostEmailSerializer),
553
- createPostSignupUrl: PostEmailSerializer.createPostSignupUrl.bind(PostEmailSerializer),
554
- renderEmailForSegment: PostEmailSerializer.renderEmailForSegment.bind(PostEmailSerializer),
555
- parseReplacements: PostEmailSerializer.parseReplacements.bind(PostEmailSerializer),
556
- // Export for tests
557
- _getTemplateSettings: PostEmailSerializer.getTemplateSettings.bind(PostEmailSerializer),
558
- _PostEmailSerializer: PostEmailSerializer
559
- };
@@ -1,20 +0,0 @@
1
- const getSegmentsFromHtml = (html) => {
2
- const cheerio = require('cheerio');
3
- const $ = cheerio.load(html);
4
-
5
- let allSegments = $('[data-gh-segment]')
6
- .get()
7
- .map(el => el.attribs['data-gh-segment']);
8
-
9
- /**
10
- * Always add free and paid segments if email has paywall card
11
- */
12
- if (html.indexOf('<!--members-only-->') !== -1) {
13
- allSegments = allSegments.concat(['status:free', 'status:-free']);
14
- }
15
-
16
- // only return unique elements
17
- return [...new Set(allSegments)];
18
- };
19
-
20
- module.exports.getSegmentsFromHtml = getSegmentsFromHtml;