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
@@ -0,0 +1,1263 @@
1
+ /* eslint-disable no-unused-vars */
2
+ /* eslint-disable no-shadow */
3
+
4
+ const logging = require('@tryghost/logging');
5
+ const fs = require('fs').promises;
6
+ const path = require('path');
7
+ const {isUnsplashImage} = require('@tryghost/kg-default-cards/lib/utils');
8
+ const {textColorForBackgroundColor, darkenToContrastThreshold} = require('@tryghost/color-utils');
9
+ const {DateTime} = require('luxon');
10
+ const htmlToPlaintext = require('@tryghost/html-to-plaintext');
11
+ const EmailAddressParser = require('../email-address/EmailAddressParser');
12
+ const {registerHelpers} = require('./helpers/register-helpers');
13
+ const crypto = require('crypto');
14
+
15
+ const DEFAULT_LOCALE = 'en-gb';
16
+
17
+ // Wrapper function so that i18next-parser can find these strings
18
+ const t = (x) => {
19
+ return x;
20
+ };
21
+
22
+ const messages = {
23
+ subscriptionStatus: {
24
+ free: '',
25
+ expired: t('Your subscription has expired.'),
26
+ canceled: t('Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.'),
27
+ active: t('Your subscription will renew on {date}.'),
28
+ trial: t('Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.'),
29
+ complimentaryExpires: t('Your subscription will expire on {date}.'),
30
+ complimentaryInfinite: ''
31
+ }
32
+ };
33
+
34
+ function escapeHtml(unsafe) {
35
+ return unsafe
36
+ .replace(/&/g, '&')
37
+ .replace(/</g, '&lt;')
38
+ .replace(/>/g, '&gt;')
39
+ .replace(/"/g, '&quot;')
40
+ .replace(/'/g, '&#039;');
41
+ }
42
+
43
+ function isValidLocale(locale) {
44
+ try {
45
+ // Attempt to create a DateTimeFormat with the locale
46
+ new Intl.DateTimeFormat(locale);
47
+ return true; // No error means it's a valid locale
48
+ } catch (e) {
49
+ return false; // RangeError means invalid locale
50
+ }
51
+ }
52
+
53
+ function formatDateLong(date, timezone, locale = DEFAULT_LOCALE) {
54
+ return DateTime.fromJSDate(date).setZone(timezone).setLocale(locale).toLocaleString({
55
+ year: 'numeric',
56
+ month: 'long',
57
+ day: 'numeric'
58
+ });
59
+ }
60
+
61
+ function escapeRegExp(string) {
62
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
63
+ }
64
+
65
+ // This aids with lazyloading the cheerio dependency
66
+ function cheerioLoad(html) {
67
+ const cheerio = require('cheerio');
68
+ return cheerio.load(html);
69
+ }
70
+
71
+ /**
72
+ * @typedef {string|null} Segment
73
+ * @typedef {object} Post
74
+ * @typedef {object} Newsletter
75
+ */
76
+
77
+ /**
78
+ * @typedef {object} MemberLike
79
+ * @prop {string} id
80
+ * @prop {string} uuid
81
+ * @prop {string} email
82
+ * @prop {string} name
83
+ * @prop {'free'|'paid'|'comped'} status
84
+ * @prop {Date|null} createdAt This can be null if the member has been deleted for older email recipient rows
85
+ * @prop {MemberLikeSubscription[]} subscriptions Required to get trial end / next renewal date / expire at date for paid member
86
+ * @prop {MemberLikeTier[]} tiers Required to get the expiry date in case of a comped member
87
+ *
88
+ * @typedef {object} MemberLikeSubscription
89
+ * @prop {string} status
90
+ * @prop {boolean} cancel_at_period_end
91
+ * @prop {Date|null} trial_end_at
92
+ * @prop {Date} current_period_end
93
+ *
94
+ * @typedef {object} MemberLikeTier
95
+ * @prop {string} product_id
96
+ * @prop {Date|null} expiry_at
97
+ */
98
+
99
+ /**
100
+ * @typedef {object} ReplacementDefinition
101
+ * @prop {string} id
102
+ * @prop {RegExp} token
103
+ * @prop {(member: MemberLike) => string} getValue
104
+ */
105
+
106
+ /**
107
+ * @typedef {object} EmailRenderOptions
108
+ * @prop {boolean} clickTrackingEnabled
109
+ */
110
+
111
+ /**
112
+ * @typedef {object} EmailBody
113
+ * @prop {string} html
114
+ * @prop {string} plaintext
115
+ * @prop {ReplacementDefinition[]} replacements
116
+ */
117
+
118
+ class EmailRenderer {
119
+ #settingsCache;
120
+ #settingsHelpers;
121
+
122
+ #renderers;
123
+
124
+ #imageSize;
125
+ #urlUtils;
126
+ #getPostUrl;
127
+ #storageUtils;
128
+
129
+ #handlebars;
130
+ #renderTemplate;
131
+ #linkReplacer;
132
+ #linkTracking;
133
+ #memberAttributionService;
134
+ #outboundLinkTagger;
135
+ #audienceFeedbackService;
136
+ #emailAddressService;
137
+ #labs;
138
+ #models;
139
+ #t;
140
+
141
+ /**
142
+ * @param {object} dependencies
143
+ * @param {object} dependencies.settingsCache
144
+ * @param {{getNoReplyAddress(): string, getMembersSupportAddress(): string, getMembersValidationKey(): string, createUnsubscribeUrl(uuid: string, options: object): string}} dependencies.settingsHelpers
145
+ * @param {object} dependencies.renderers
146
+ * @param {{render(object, options): string}} dependencies.renderers.lexical
147
+ * @param {{render(object, options): string}} dependencies.renderers.mobiledoc
148
+ * @param {{getImageSizeFromUrl(url: string): Promise<{width: number, height: number}>}} dependencies.imageSize
149
+ * @param {{urlFor(type: string, optionsOrAbsolute, absolute): string, isSiteUrl(url, context): boolean}} dependencies.urlUtils
150
+ * @param {{isLocalImage(url: string): boolean}} dependencies.storageUtils
151
+ * @param {(post: Post) => string} dependencies.getPostUrl
152
+ * @param {object} dependencies.linkReplacer
153
+ * @param {object} dependencies.linkTracking
154
+ * @param {object} dependencies.memberAttributionService
155
+ * @param {object} dependencies.audienceFeedbackService
156
+ * @param {object} dependencies.emailAddressService
157
+ * @param {object} dependencies.outboundLinkTagger
158
+ * @param {object} dependencies.labs
159
+ * @param {{Post: object}} dependencies.models
160
+ * @param {Function} dependencies.t
161
+ */
162
+ constructor({
163
+ settingsCache,
164
+ settingsHelpers,
165
+ renderers,
166
+ imageSize,
167
+ urlUtils,
168
+ storageUtils,
169
+ getPostUrl,
170
+ linkReplacer,
171
+ linkTracking,
172
+ memberAttributionService,
173
+ audienceFeedbackService,
174
+ emailAddressService,
175
+ outboundLinkTagger,
176
+ labs,
177
+ models,
178
+ t
179
+ }) {
180
+ this.#settingsCache = settingsCache;
181
+ this.#settingsHelpers = settingsHelpers;
182
+ this.#renderers = renderers;
183
+ this.#imageSize = imageSize;
184
+ this.#urlUtils = urlUtils;
185
+ this.#storageUtils = storageUtils;
186
+ this.#getPostUrl = getPostUrl;
187
+ this.#linkReplacer = linkReplacer;
188
+ this.#linkTracking = linkTracking;
189
+ this.#memberAttributionService = memberAttributionService;
190
+ this.#audienceFeedbackService = audienceFeedbackService;
191
+ this.#emailAddressService = emailAddressService;
192
+ this.#outboundLinkTagger = outboundLinkTagger;
193
+ this.#labs = labs;
194
+ this.#models = models;
195
+ this.#t = t;
196
+ }
197
+
198
+ getSubject(post, isTestEmail = false) {
199
+ const subject = post.related('posts_meta')?.get('email_subject') || post.get('title');
200
+ return isTestEmail ? `[TEST] ${subject}` : subject;
201
+ }
202
+
203
+ #getRawFromAddress(post, newsletter) {
204
+ let senderName = this.#settingsCache.get('title') ? this.#settingsCache.get('title').replace(/"/g, '\\"') : '';
205
+ if (newsletter.get('sender_name')) {
206
+ senderName = newsletter.get('sender_name');
207
+ }
208
+
209
+ let fromAddress = this.#settingsHelpers.getNoReplyAddress();
210
+ if (newsletter.get('sender_email')) {
211
+ fromAddress = newsletter.get('sender_email');
212
+ }
213
+
214
+ // For local development, rewrite the fromAddress to a proper domain
215
+ if (process.env.NODE_ENV !== 'production') {
216
+ if (/@localhost$/.test(fromAddress) || /@ghost.local$/.test(fromAddress)) {
217
+ const localAddress = 'localhost@example.com';
218
+ logging.warn(`Rewriting bulk email from address ${fromAddress} to ${localAddress}`);
219
+ fromAddress = localAddress;
220
+ }
221
+ }
222
+ return {
223
+ address: fromAddress,
224
+ name: senderName || undefined
225
+ };
226
+ }
227
+
228
+ // Locale is user-input, so we need to ensure it's valid
229
+ #getValidLocale() {
230
+ let locale = this.#settingsCache.get('locale') || DEFAULT_LOCALE;
231
+
232
+ if (!this.#labs.isSet('i18n')) {
233
+ locale = DEFAULT_LOCALE;
234
+ }
235
+
236
+ // Remove any trailing whitespace
237
+ locale = locale.trim();
238
+
239
+ // If the locale is just "en", or is not valid, revert to default
240
+ if (locale === 'en' || !isValidLocale(locale)) {
241
+ locale = DEFAULT_LOCALE;
242
+ }
243
+
244
+ return locale;
245
+ }
246
+
247
+ getFromAddress(post, newsletter) {
248
+ // Clean from address to ensure DMARC alignment
249
+ const addresses = this.#emailAddressService.getAddress({
250
+ from: this.#getRawFromAddress(post, newsletter)
251
+ });
252
+
253
+ return EmailAddressParser.stringify(addresses.from);
254
+ }
255
+
256
+ /**
257
+ * @param {Post} post
258
+ * @param {Newsletter} newsletter
259
+ * @returns {string|null}
260
+ */
261
+ getReplyToAddress(post, newsletter) {
262
+ const replyToAddress = newsletter.get('sender_reply_to');
263
+
264
+ if (replyToAddress === 'support') {
265
+ return this.#settingsHelpers.getMembersSupportAddress();
266
+ }
267
+
268
+ if (replyToAddress === 'newsletter' && !this.#emailAddressService.managedEmailEnabled) {
269
+ return this.getFromAddress(post, newsletter);
270
+ }
271
+
272
+ const addresses = this.#emailAddressService.getAddress({
273
+ from: this.#getRawFromAddress(post, newsletter),
274
+ replyTo: replyToAddress === 'newsletter' ? undefined : {address: replyToAddress}
275
+ });
276
+
277
+ if (addresses.replyTo) {
278
+ return EmailAddressParser.stringify(addresses.replyTo);
279
+ }
280
+ return null;
281
+ }
282
+
283
+ /**
284
+ Returns all the segments that we need to render the email for because they have different content.
285
+ WARNING: The sum of all the returned segments should always include all the members. Those members are later limited if needed based on the recipient filter of the email.
286
+ @param {Post} post
287
+ @returns {Promise<Segment[]>}
288
+ */
289
+ async getSegments(post) {
290
+ const allowedSegments = ['status:free', 'status:-free'];
291
+ const html = await this.renderPostBaseHtml(post);
292
+
293
+ /**
294
+ * Always add free and paid segments if email has paywall card
295
+ */
296
+ if (html.indexOf('<!--members-only-->') !== -1) {
297
+ // We have different content between free and paid members
298
+ return allowedSegments;
299
+ }
300
+
301
+ const $ = cheerioLoad(html);
302
+
303
+ let allSegments = $('[data-gh-segment]')
304
+ .get()
305
+ .map(el => el.attribs['data-gh-segment']);
306
+
307
+ const segments = [...new Set(allSegments)].filter(segment => allowedSegments.includes(segment));
308
+ if (segments.length === 0) {
309
+ // No difference in email content between free and paid
310
+ return [null];
311
+ }
312
+
313
+ // We have different content between free and paid members
314
+ return allowedSegments;
315
+ }
316
+
317
+ async renderPostBaseHtml(post, newsletter) {
318
+ const postUrl = this.#getPostUrl(post);
319
+
320
+ const renderOptions = {
321
+ target: 'email',
322
+ postUrl
323
+ };
324
+
325
+ if (this.getLabs()?.isSet('emailCustomizationAlpha')) {
326
+ renderOptions.design = {
327
+ buttonCorners: newsletter?.get('button_corners'),
328
+ buttonStyle: newsletter?.get('button_style')
329
+ };
330
+ }
331
+
332
+ let html;
333
+ if (post.get('lexical')) {
334
+ // only lexical's renderer is async
335
+ html = await this.#renderers.lexical.render(
336
+ post.get('lexical'),
337
+ renderOptions
338
+ );
339
+ } else {
340
+ html = this.#renderers.mobiledoc.render(
341
+ JSON.parse(post.get('mobiledoc')), {target: 'email', postUrl}
342
+ );
343
+ }
344
+ return html;
345
+ }
346
+
347
+ /**
348
+ *
349
+ * @param {Post} post
350
+ * @param {Newsletter} newsletter
351
+ * @param {Segment} segment
352
+ * @param {EmailRenderOptions} options
353
+ * @returns {Promise<EmailBody>}
354
+ */
355
+ async renderBody(post, newsletter, segment, options) {
356
+ let html = await this.renderPostBaseHtml(post, newsletter);
357
+
358
+ // We don't allow the usage of the %%{uuid}%% replacement in the email body (only in links and special cases)
359
+ // So we need to filter them before we introduce the real %%{uuid}%%
360
+ html = html.replace(/%%{uuid}%%/g, '{uuid}');
361
+
362
+ // Paywall and members only content handling
363
+ const isPaidPost = post.get('visibility') === 'paid' || post.get('visibility') === 'tiers';
364
+ const membersOnlyIndex = html.indexOf('<!--members-only-->');
365
+ const hasMembersOnlyContent = membersOnlyIndex !== -1;
366
+ let addPaywall = false;
367
+
368
+ if (isPaidPost && hasMembersOnlyContent) {
369
+ if (segment === 'status:free') {
370
+ // Add paywall
371
+ addPaywall = true;
372
+
373
+ // Remove the members-only content
374
+ html = html.slice(0, membersOnlyIndex);
375
+ }
376
+ }
377
+
378
+ let $ = cheerioLoad(html);
379
+
380
+ // Remove parts of the HTML not applicable to the current segment - We do this
381
+ // before rendering the template as the preheader for the email may be generated
382
+ // using the HTML and we don't want to include content that should not be
383
+ // visible depending on the segment
384
+ $('[data-gh-segment]').get().forEach((node) => {
385
+ // TODO: replace with NQL interpretation
386
+ if (node.attribs['data-gh-segment'] !== segment) {
387
+ $(node).remove();
388
+ } else {
389
+ // Getting rid of the attribute for a cleaner html output
390
+ $(node).removeAttr('data-gh-segment');
391
+ }
392
+ });
393
+
394
+ html = $.html();
395
+
396
+ const templateData = await this.getTemplateData({
397
+ post,
398
+ newsletter,
399
+ html,
400
+ addPaywall,
401
+ segment
402
+ });
403
+ html = await this.renderTemplate(templateData);
404
+
405
+ // We pass the base option to the link replacer so relative links are replaced with absolute links, relative to this base url
406
+ const base = templateData.post.url;
407
+
408
+ // Link tracking
409
+ if (options.clickTrackingEnabled) {
410
+ html = await this.#linkReplacer.replace(html, async (url, originalPath) => {
411
+ if (originalPath.startsWith('%%{') && originalPath.endsWith('}%%')) {
412
+ // Don't add the base url to replacement strings
413
+ return originalPath;
414
+ }
415
+
416
+ // Ignore empty hashtags (used as a hack for email addresses to prevent making them clickable)
417
+ if (originalPath === '#') {
418
+ return originalPath;
419
+ }
420
+
421
+ // We ignore all links that contain %%{uuid}%%
422
+ // because otherwise we would add tracking to links that need to be replaced first
423
+ if (url.toString().indexOf('%%{uuid}%%') !== -1) {
424
+ return url.toString();
425
+ }
426
+
427
+ // Add newsletter source attribution
428
+ const isSite = this.#urlUtils.isSiteUrl(url);
429
+
430
+ if (isSite) {
431
+ // Add newsletter name as ref to the URL
432
+ url = this.#outboundLinkTagger.addToUrl(url, newsletter);
433
+
434
+ // Only add post attribution to our own site (because external sites could/should not process this information)
435
+ url = this.#memberAttributionService.addPostAttributionTracking(url, post);
436
+ } else {
437
+ // Add email source attribution without the newsletter name
438
+ url = this.#outboundLinkTagger.addToUrl(url);
439
+ }
440
+
441
+ // Don't add tracking to the Powered by Ghost badge
442
+ if (url.hostname === 'ghost.org' && url.pathname === '/' && url.searchParams.get('via') === 'pbg-newsletter') {
443
+ return url.toString();
444
+ }
445
+
446
+ // Add link click tracking
447
+ url = await this.#linkTracking.service.addTrackingToUrl(url, post, '--uuid--');
448
+
449
+ // We need to convert to a string at this point, because we need invalid string characters in the URL
450
+ const str = url.toString().replace(/--uuid--/g, '%%{uuid}%%');
451
+ return str;
452
+ }, {base});
453
+ } else {
454
+ // Replace all relative links to absolute ones
455
+ html = await this.#linkReplacer.replace(html, (url, originalPath) => {
456
+ if (originalPath.startsWith('%%{') && originalPath.endsWith('}%%')) {
457
+ // Don't add the base url to replacement strings
458
+ return originalPath;
459
+ }
460
+
461
+ // Ignore empty hashtags (used as a hack for email addresses to prevent making them clickable)
462
+ if (originalPath === '#') {
463
+ return originalPath;
464
+ }
465
+ return url;
466
+ }, {base});
467
+ }
468
+
469
+ // Record the original image width and height attributes before inlining the styles with juice
470
+ // If any images have `width: auto` or `height: auto` set via CSS,
471
+ // juice will explicitly set the width/height attributes to `auto` on the <img /> tag
472
+ // This is not supported by Outlook, so we need to reset the width/height attributes to the original values
473
+ // Other clients will ignore the width/height attributes and use the inlined CSS instead
474
+ $ = cheerioLoad(html);
475
+ const originalImageSizes = $('img').get().map((image) => {
476
+ const src = image.attribs.src;
477
+ const width = image.attribs.width;
478
+ const height = image.attribs.height;
479
+ return {src, width, height};
480
+ });
481
+
482
+ // Add a class to each figcaption so we can style them in the email
483
+ $('figcaption').each((i, elem) => !!($(elem).addClass('kg-card-figcaption')));
484
+ html = $.html();
485
+
486
+ // Juice HTML (inline CSS)
487
+ const juice = require('juice');
488
+ html = juice(html, {inlinePseudoElements: true, removeStyleTags: true});
489
+
490
+ // happens after inlining of CSS so we can change element types without worrying about styling
491
+ $ = cheerioLoad(html);
492
+
493
+ // Reset any `height="auto"` or `width="auto"` attributes to their original values before inlining CSS
494
+ const imageTags = $('img').get();
495
+ for (let i = 0; i < imageTags.length; i += 1) {
496
+ // There shouldn't be any issues with consistency between these two lists, but just in case...
497
+ if (imageTags[i].attribs.src === originalImageSizes[i].src) {
498
+ // if the image width or height is set to 'auto', reset to its original value
499
+ if (imageTags[i].attribs.width === 'auto' && originalImageSizes[i].width) {
500
+ imageTags[i].attribs.width = originalImageSizes[i].width;
501
+ }
502
+ if (imageTags[i].attribs.height === 'auto' && originalImageSizes[i].height) {
503
+ imageTags[i].attribs.height = originalImageSizes[i].height;
504
+ }
505
+ }
506
+ }
507
+
508
+ // force all links to open in new tab
509
+ $('a').attr('target', '_blank');
510
+
511
+ // convert figure and figcaption to div so that Outlook applies margins
512
+ $('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
513
+
514
+ // Remove duplicate black/white images (CSS based solution not working in Outlook)
515
+ if (templateData.backgroundIsDark) {
516
+ $('img.is-light-background').each((i, elem) => {
517
+ $(elem).remove();
518
+ });
519
+ } else {
520
+ $('img.is-dark-background').each((i, elem) => {
521
+ $(elem).remove();
522
+ });
523
+ }
524
+
525
+ // Convert DOM back to HTML
526
+ html = $.html(); // () Fix for vscode syntax highlighter
527
+
528
+ // Replacement strings
529
+ const replacementDefinitions = this.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
530
+
531
+ // TODO: normalizeReplacementStrings (replace unsupported replacement strings)
532
+
533
+ // Convert HTML to plaintext
534
+ const plaintext = htmlToPlaintext.email(html);
535
+
536
+ // Fix any unsupported chars in Outlook
537
+ html = html.replace(/&apos;/g, '&#39;');
538
+ html = html.replace(/→/g, '&rarr;');
539
+ html = html.replace(/–/g, '&ndash;');
540
+ html = html.replace(/“/g, '&ldquo;');
541
+ html = html.replace(/”/g, '&rdquo;');
542
+
543
+ return {
544
+ html,
545
+ plaintext,
546
+ replacements: replacementDefinitions
547
+ };
548
+ }
549
+
550
+ /**
551
+ * createUnsubscribeUrl
552
+ *
553
+ * Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
554
+ * In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
555
+ *
556
+ * @param {string} [uuid] member uuid
557
+ * @param {Object} [options]
558
+ * @param {string} [options.newsletterUuid] newsletter uuid
559
+ * @param {boolean} [options.comments] Unsubscribe from comment emails
560
+ */
561
+ createUnsubscribeUrl(uuid, options = {}) {
562
+ return this.#settingsHelpers.createUnsubscribeUrl(uuid, options);
563
+ }
564
+
565
+ /**
566
+ * createManageAccountUrl
567
+ *
568
+ * @param {string} [uuid] member uuid
569
+ */
570
+ createManageAccountUrl(uuid) {
571
+ const siteUrl = this.#urlUtils.urlFor('home', true);
572
+ const url = new URL(siteUrl);
573
+ url.hash = '#/portal/account';
574
+
575
+ return url.href;
576
+ }
577
+
578
+ /**
579
+ * Returns whether a paid member is trialing a subscription
580
+ */
581
+ isMemberTrialing(member) {
582
+ // Do we have an active subscription?
583
+ if (member.status === 'paid') {
584
+ let activeSubscription = member.subscriptions.find((subscription) => {
585
+ return subscription.status === 'trialing';
586
+ });
587
+
588
+ if (!activeSubscription) {
589
+ return false;
590
+ }
591
+
592
+ // Translate to a human readable string
593
+ if (activeSubscription.trial_end_at && activeSubscription.trial_end_at > new Date() && activeSubscription.status === 'trialing') {
594
+ return true;
595
+ }
596
+ }
597
+
598
+ return false;
599
+ }
600
+
601
+ /**
602
+ * @param {MemberLike} member
603
+ * @returns {string}
604
+ */
605
+ getMemberStatusText(member) {
606
+ const t = this.#t;
607
+ const locale = this.#getValidLocale();
608
+
609
+ if (member.status === 'free') {
610
+ // Not really used, but as a backup
611
+ return t(messages.subscriptionStatus.free);
612
+ }
613
+
614
+ // Do we have an active subscription?
615
+ if (member.status === 'paid') {
616
+ let activeSubscription = member.subscriptions.find((subscription) => {
617
+ return subscription.status === 'active';
618
+ }) ?? member.subscriptions.find((subscription) => {
619
+ return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.status);
620
+ });
621
+
622
+ if (!activeSubscription && !member.tiers.length) {
623
+ // No subscription?
624
+ return t(messages.subscriptionStatus.expired);
625
+ }
626
+
627
+ if (!activeSubscription) {
628
+ if (!member.tiers[0]?.expiry_at) {
629
+ return t(messages.subscriptionStatus.complimentaryInfinite);
630
+ }
631
+ // Create one manually that is expiring
632
+ activeSubscription = {
633
+ cancel_at_period_end: true,
634
+ current_period_end: member.tiers[0].expiry_at,
635
+ status: 'active',
636
+ trial_end_at: null
637
+ };
638
+ }
639
+ const timezone = this.#settingsCache.get('timezone');
640
+ // Translate to a human readable string
641
+ if (activeSubscription.trial_end_at && activeSubscription.trial_end_at > new Date() && activeSubscription.status === 'trialing') {
642
+ const date = formatDateLong(activeSubscription.trial_end_at, timezone, locale);
643
+ return t(messages.subscriptionStatus.trial, {date});
644
+ }
645
+
646
+ const date = formatDateLong(activeSubscription.current_period_end, timezone, locale);
647
+ if (activeSubscription.cancel_at_period_end) {
648
+ return t(messages.subscriptionStatus.canceled, {date});
649
+ }
650
+ return t(messages.subscriptionStatus.active, {date});
651
+ }
652
+
653
+ const expires = member.tiers[0]?.expiry_at ?? null;
654
+
655
+ if (expires) {
656
+ const timezone = this.#settingsCache.get('timezone');
657
+ const date = formatDateLong(expires, timezone, locale);
658
+ return t(messages.subscriptionStatus.complimentaryExpires, {date});
659
+ }
660
+
661
+ return t(messages.subscriptionStatus.complimentaryInfinite);
662
+ }
663
+
664
+ /**
665
+ * Note that we only look in HTML because plaintext and HTML are essentially the same content
666
+ * @returns {ReplacementDefinition[]}
667
+ */
668
+ buildReplacementDefinitions({html, newsletterUuid}) {
669
+ const t = this.#t; // es-lint-disable-line no-shadow
670
+ const locale = this.#getValidLocale();
671
+
672
+ const baseDefinitions = [
673
+ {
674
+ id: 'unsubscribe_url',
675
+ getValue: (member) => {
676
+ return this.createUnsubscribeUrl(member.uuid, {newsletterUuid});
677
+ }
678
+ },
679
+ {
680
+ id: 'manage_account_url',
681
+ getValue: (member) => {
682
+ return this.createManageAccountUrl(member.uuid);
683
+ }
684
+ },
685
+ {
686
+ id: 'uuid',
687
+ getValue: (member) => {
688
+ return member.uuid;
689
+ }
690
+ },
691
+ {
692
+ id: 'key',
693
+ getValue: (member) => {
694
+ return crypto.createHmac('sha256', this.#settingsHelpers.getMembersValidationKey()).update(member.uuid).digest('hex');
695
+ }
696
+ },
697
+ {
698
+ id: 'first_name',
699
+ getValue: (member) => {
700
+ return member.name?.split(' ')[0];
701
+ }
702
+ },
703
+ {
704
+ id: 'name',
705
+ getValue: (member) => {
706
+ return member.name;
707
+ }
708
+ },
709
+ {
710
+ id: 'name_class',
711
+ getValue: (member) => {
712
+ return member.name ? '' : 'hidden';
713
+ }
714
+ },
715
+ {
716
+ id: 'email',
717
+ getValue: (member) => {
718
+ return member.email;
719
+ }
720
+ },
721
+ {
722
+ id: 'created_at',
723
+ getValue: (member) => {
724
+ const timezone = this.#settingsCache.get('timezone');
725
+ return member.createdAt ? formatDateLong(member.createdAt, timezone, locale) : '';
726
+ }
727
+ },
728
+ {
729
+ id: 'status',
730
+ getValue: (member) => {
731
+ if (member.status === 'comped') {
732
+ return t('complimentary');
733
+ }
734
+ if (this.isMemberTrialing(member)) {
735
+ return t('trialing');
736
+ }
737
+ // other possible statuses: t('free'), t('paid') //
738
+ return t(member.status);
739
+ }
740
+ },
741
+ {
742
+ //TODO i18n
743
+ id: 'status_text',
744
+ getValue: (member) => {
745
+ return this.getMemberStatusText(member);
746
+ }
747
+ },
748
+ // List unsubscribe header to unsubcribe in one-click
749
+ {
750
+ id: 'list_unsubscribe',
751
+ getValue: (member) => {
752
+ return this.createUnsubscribeUrl(member.uuid, {newsletterUuid});
753
+ },
754
+ required: true // Used in email headers
755
+ }
756
+ ];
757
+
758
+ // Now loop through all the definenitions to see which ones are actually used + to add fallbacks if needed
759
+ const EMAIL_REPLACEMENT_REGEX = /%%\{(.*?)\}%%/g;
760
+ const REPLACEMENT_STRING_REGEX = /^(?<recipientProperty>\w+?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?$/;
761
+
762
+ // Stores the definitions that we are actually going to use
763
+ const replacements = [];
764
+
765
+ let result;
766
+ while ((result = EMAIL_REPLACEMENT_REGEX.exec(html)) !== null) {
767
+ const [replacementMatch, replacementStr] = result;
768
+
769
+ // Did we already found this match and added it to the replacements array?
770
+ if (replacements.find(r => r.id === replacementStr)) {
771
+ continue;
772
+ }
773
+ const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
774
+
775
+ if (match) {
776
+ const {recipientProperty, fallback} = match.groups;
777
+ const definition = baseDefinitions.find(d => d.id === recipientProperty);
778
+
779
+ if (definition) {
780
+ replacements.push({
781
+ id: replacementStr,
782
+ originalId: recipientProperty,
783
+ token: new RegExp(escapeRegExp(replacementMatch).replace(/(?:"|&quot;)/g, '(?:"|&quot;)'), 'g'),
784
+ getValue: fallback ? (member => definition.getValue(member) || fallback) : definition.getValue
785
+ });
786
+ }
787
+ }
788
+ }
789
+
790
+ // Add all required replacements
791
+ for (const definition of baseDefinitions) {
792
+ if (definition.required && !replacements.find(r => r.id === definition.id)) {
793
+ replacements.push({
794
+ id: definition.id,
795
+ originalId: definition.id,
796
+ token: new RegExp(`%%\\{${definition.id}\\}%%`, 'g'),
797
+ getValue: definition.getValue
798
+ });
799
+ }
800
+ }
801
+
802
+ // Now loop any replacements with possible invalid characters and replace them with a clean id
803
+ let counter = 1;
804
+ for (const replacement of replacements) {
805
+ if (replacement.id.match(/[^a-zA-Z0-9_]/)) {
806
+ counter += 1;
807
+ replacement.id = replacement.originalId + '_' + counter;
808
+ }
809
+ delete replacement.originalId;
810
+ }
811
+ return replacements;
812
+ }
813
+
814
+ getLabs() {
815
+ return this.#labs;
816
+ }
817
+
818
+ async renderTemplate(data) {
819
+ const labs = this.getLabs();
820
+ this.#handlebars = require('handlebars').create();
821
+
822
+ // Register helpers
823
+ registerHelpers(this.#handlebars, labs, this.#t);
824
+
825
+ // Partials
826
+ const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles.hbs`), 'utf8');
827
+ this.#handlebars.registerPartial('styles', cssPartialSource);
828
+
829
+ const paywallPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `paywall.hbs`), 'utf8');
830
+ this.#handlebars.registerPartial('paywall', paywallPartial);
831
+
832
+ const feedbackButtonPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `feedback-button.hbs`), 'utf8');
833
+ this.#handlebars.registerPartial('feedbackButton', feedbackButtonPartial);
834
+
835
+ const latestPostsPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `latest-posts.hbs`), 'utf8');
836
+ this.#handlebars.registerPartial('latestPosts', latestPostsPartial);
837
+
838
+ // Actual template
839
+ const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template.hbs`), 'utf8');
840
+ this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString());
841
+
842
+ return this.#renderTemplate(data);
843
+ }
844
+
845
+ /**
846
+ * Get email preheader text from post model
847
+ * @param {object} postModel
848
+ * @returns
849
+ */
850
+ #getEmailPreheader(postModel, segment, html) {
851
+ let plaintext = postModel.get('plaintext');
852
+ let customExcerpt = postModel.get('custom_excerpt');
853
+ if (customExcerpt) {
854
+ return customExcerpt;
855
+ } else {
856
+ if (plaintext) {
857
+ // The plaintext field on the model may contain paid only content
858
+ // so we use the provided HTML to generate the plaintext as this
859
+ // should have already had the paid content removed
860
+ if (segment === 'status:free') {
861
+ plaintext = htmlToPlaintext.email(html);
862
+ }
863
+ return plaintext.substring(0, 500);
864
+ } else {
865
+ return `${postModel.get('title')} – `;
866
+ }
867
+ }
868
+ }
869
+
870
+ truncateText(text, maxLength) {
871
+ if (text && text.length > maxLength) {
872
+ return text.substring(0, maxLength - 1).trim() + '…';
873
+ } else {
874
+ return text ?? '';
875
+ }
876
+ }
877
+
878
+ /**
879
+ *
880
+ * @param {*} text
881
+ * @param {number} maxLength
882
+ * @param {number} maxLengthMobile should be smaller than maxLength
883
+ * @returns
884
+ */
885
+ truncateHtml(text, maxLength, maxLengthMobile) {
886
+ if (!maxLengthMobile || maxLength <= maxLengthMobile) {
887
+ return escapeHtml(this.truncateText(text, maxLength));
888
+ }
889
+ if (text && text.length > maxLengthMobile) {
890
+ let ellipsis = '';
891
+
892
+ if (text.length > maxLengthMobile && text.length <= maxLength) {
893
+ ellipsis = '<span class="hide-desktop">…</span>';
894
+ } else if (text.length > maxLength) {
895
+ ellipsis = '…';
896
+ }
897
+
898
+ return escapeHtml(text.substring(0, maxLengthMobile - 1)) + '<span class="desktop-only">' + escapeHtml(text.substring(maxLengthMobile - 1, maxLength - 1)) + '</span>' + ellipsis;
899
+ } else {
900
+ return escapeHtml(text ?? '');
901
+ }
902
+ }
903
+
904
+ #getBackgroundColor(newsletter) {
905
+ /** @type {'light' | 'dark' | string | null} */
906
+ const value = newsletter.get('background_color');
907
+
908
+ const validHex = /#([0-9a-f]{3}){1,2}$/i;
909
+
910
+ if (validHex.test(value)) {
911
+ return value;
912
+ }
913
+
914
+ if (value === 'dark') {
915
+ return '#15212a';
916
+ }
917
+
918
+ // value === dark, value === null, value is not valid hex
919
+ return '#ffffff';
920
+ }
921
+
922
+ #getBorderColor(newsletter, accentColor) {
923
+ /** @type {'transparent' | 'accent' | 'dark' | string | null} */
924
+ const value = newsletter.get('border_color');
925
+
926
+ const validHex = /#([0-9a-f]{3}){1,2}$/i;
927
+
928
+ if (validHex.test(value)) {
929
+ return value;
930
+ }
931
+
932
+ if (value === 'auto') {
933
+ const backgroundColor = this.#getBackgroundColor(newsletter);
934
+ return textColorForBackgroundColor(backgroundColor).hex();
935
+ }
936
+
937
+ if (value === 'accent') {
938
+ return accentColor;
939
+ }
940
+
941
+ // value === 'transparent', value === null, value is not valid hex
942
+ return null;
943
+ }
944
+
945
+ #getTitleColor(newsletter, accentColor) {
946
+ /** @type {'accent' | 'auto' | string | null} */
947
+ const value = newsletter.get('title_color');
948
+
949
+ const validHex = /#([0-9a-f]{3}){1,2}$/i;
950
+
951
+ if (validHex.test(value)) {
952
+ return value;
953
+ }
954
+
955
+ if (value === 'accent') {
956
+ return accentColor;
957
+ }
958
+
959
+ // value === 'auto', value === null, value is not valid hex
960
+ const backgroundColor = this.#getBackgroundColor(newsletter);
961
+ return textColorForBackgroundColor(backgroundColor).hex();
962
+ }
963
+
964
+ /**
965
+ * @private
966
+ */
967
+ async getTemplateData({post, newsletter, html, addPaywall, segment}) {
968
+ const labs = this.getLabs();
969
+
970
+ let accentColor = this.#settingsCache.get('accent_color') || '#15212A';
971
+ let adjustedAccentColor;
972
+ let adjustedAccentContrastColor;
973
+ try {
974
+ adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
975
+ adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
976
+ } catch (e) {
977
+ logging.error(e);
978
+ accentColor = '#15212A';
979
+ }
980
+
981
+ const backgroundColor = this.#getBackgroundColor(newsletter);
982
+ const backgroundIsDark = textColorForBackgroundColor(backgroundColor).hex().toLowerCase() === '#ffffff';
983
+ const borderColor = this.#getBorderColor(newsletter, accentColor);
984
+ const secondaryBorderColor = textColorForBackgroundColor(backgroundColor).alpha(0.12).toString();
985
+ const titleColor = this.#getTitleColor(newsletter, accentColor);
986
+ const textColor = textColorForBackgroundColor(backgroundColor).hex();
987
+ const secondaryTextColor = textColorForBackgroundColor(backgroundColor).alpha(0.5).toString();
988
+ const linkColor = backgroundIsDark ? '#ffffff' : accentColor;
989
+
990
+ let buttonBorderRadius = '6px';
991
+
992
+ if (labs.isSet('emailCustomizationAlpha')) {
993
+ if (newsletter.get('button_corners') === 'square') {
994
+ buttonBorderRadius = '0';
995
+ } else if (newsletter.get('button_corners') === 'pill') {
996
+ buttonBorderRadius = '9999px';
997
+ }
998
+ }
999
+
1000
+ let hasOutlineButtons = false;
1001
+ if (labs.isSet('emailCustomizationAlpha') && newsletter.get('button_style') === 'outline') {
1002
+ hasOutlineButtons = true;
1003
+ }
1004
+
1005
+ const {href: headerImage, width: headerImageWidth} = await this.limitImageWidth(newsletter.get('header_image'));
1006
+ const {href: postFeatureImage, width: postFeatureImageWidth, height: postFeatureImageHeight} = await this.limitImageWidth(post.get('feature_image'));
1007
+
1008
+ const timezone = this.#settingsCache.get('timezone');
1009
+ const locale = this.#getValidLocale();
1010
+ const publishedAt = (post.get('published_at') ? DateTime.fromJSDate(post.get('published_at')) : DateTime.local()).setZone(timezone).setLocale(locale).toLocaleString({
1011
+ year: 'numeric',
1012
+ month: 'short',
1013
+ day: 'numeric'
1014
+ });
1015
+
1016
+ let authors;
1017
+ const postAuthors = await post.getLazyRelation('authors');
1018
+ if (postAuthors?.models) {
1019
+ if (postAuthors.models.length <= 2) {
1020
+ authors = postAuthors.models.map(author => author.get('name')).join(' & ');
1021
+ } else {
1022
+ authors = `${postAuthors.models[0].get('name')} & ${postAuthors.models.length - 1} others`;
1023
+ }
1024
+ }
1025
+
1026
+ const postUrl = this.#getPostUrl(post);
1027
+
1028
+ // Signup URL is the post url with a hash added to it
1029
+ const signupUrl = new URL(postUrl);
1030
+ signupUrl.hash = `/portal/signup`;
1031
+
1032
+ // Audience feedback
1033
+ const positiveLink = this.#audienceFeedbackService.buildLink(
1034
+ '--uuid--',
1035
+ post.id,
1036
+ 1,
1037
+ '--key--'
1038
+ ).href.replace('--uuid--', '%%{uuid}%%').replace('--key--', '%%{key}%%');
1039
+ const negativeLink = this.#audienceFeedbackService.buildLink(
1040
+ '--uuid--',
1041
+ post.id,
1042
+ 0,
1043
+ '--key--'
1044
+ ).href.replace('--uuid--', '%%{uuid}%%').replace('--key--', '%%{key}%%');
1045
+
1046
+ const commentUrl = new URL(postUrl);
1047
+ commentUrl.hash = '#ghost-comments';
1048
+
1049
+ const hasEmailOnlyFlag = post.related('posts_meta')?.get('email_only') ?? false;
1050
+
1051
+ const latestPosts = [];
1052
+ let latestPostsHasImages = false;
1053
+ if (newsletter.get('show_latest_posts')) {
1054
+ // Fetch last 3 published posts
1055
+ const {data} = await this.#models.Post.findPage({
1056
+ filter: `status:published+id:-'${post.id}'`,
1057
+ order: 'published_at DESC',
1058
+ limit: 3
1059
+ });
1060
+
1061
+ for (const latestPost of data) {
1062
+ // Please also adjust email-latest-posts-image if you make changes to the image width (100 x 2 = 200 -> should be in email-latest-posts-image)
1063
+ const {href: featureImage, width: featureImageWidth, height: featureImageHeight} = await this.limitImageWidth(latestPost.get('feature_image'), 100, 100);
1064
+ const {href: featureImageMobile, width: featureImageMobileWidth, height: featureImageMobileHeight} = await this.limitImageWidth(latestPost.get('feature_image'), 600, 480);
1065
+
1066
+ latestPosts.push({
1067
+ title: this.truncateHtml(latestPost.get('title'), featureImage ? 85 : 95, featureImageMobile ? 55 : 75),
1068
+ url: this.#getPostUrl(latestPost),
1069
+ featureImage: featureImage ? {
1070
+ src: featureImage,
1071
+ width: featureImageWidth,
1072
+ height: featureImageHeight
1073
+ } : null,
1074
+ featureImageMobile: featureImageMobile ? {
1075
+ src: featureImageMobile,
1076
+ width: featureImageMobileWidth,
1077
+ height: featureImageMobileHeight
1078
+ } : null,
1079
+ excerpt: this.truncateHtml(latestPost.get('custom_excerpt') || latestPost.get('plaintext'), featureImage ? 120 : 130, featureImageMobile ? 90 : 100)
1080
+ });
1081
+
1082
+ if (featureImage) {
1083
+ latestPostsHasImages = true;
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ let excerptFontClass = '';
1089
+ const bodyFont = newsletter.get('body_font_category');
1090
+ const titleFont = newsletter.get('title_font_category');
1091
+
1092
+ if (titleFont === 'serif' && bodyFont === 'serif') {
1093
+ excerptFontClass = 'post-excerpt-serif-serif';
1094
+ } else if (titleFont === 'serif' && bodyFont !== 'serif') {
1095
+ excerptFontClass = 'post-excerpt-serif-sans';
1096
+ }
1097
+
1098
+ const data = {
1099
+ site: {
1100
+ title: this.#settingsCache.get('title'),
1101
+ url: this.#urlUtils.urlFor('home', true),
1102
+ iconUrl: this.#settingsCache.get('icon') ?
1103
+ this.#urlUtils.urlFor('image', {
1104
+ image: this.#settingsCache.get('icon')
1105
+ }, true) : null
1106
+ },
1107
+ preheader: this.#getEmailPreheader(post, segment, html),
1108
+ html,
1109
+
1110
+ post: {
1111
+ title: post.get('title'),
1112
+ url: postUrl,
1113
+ commentUrl: commentUrl.href,
1114
+ authors,
1115
+ publishedAt,
1116
+ customExcerpt: post.get('custom_excerpt'),
1117
+ feature_image: postFeatureImage,
1118
+ feature_image_width: postFeatureImageWidth,
1119
+ feature_image_height: postFeatureImageHeight,
1120
+ feature_image_alt: post.related('posts_meta')?.get('feature_image_alt'),
1121
+ feature_image_caption: post.related('posts_meta')?.get('feature_image_caption')
1122
+ },
1123
+
1124
+ newsletter: {
1125
+ name: newsletter.get('name'),
1126
+ showPostTitleSection: newsletter.get('show_post_title_section'),
1127
+ showExcerpt: newsletter.get('show_excerpt'),
1128
+ showCommentCta: newsletter.get('show_comment_cta') && this.#settingsCache.get('comments_enabled') !== 'off' && !hasEmailOnlyFlag,
1129
+ showSubscriptionDetails: newsletter.get('show_subscription_details')
1130
+ },
1131
+ latestPosts,
1132
+ latestPostsHasImages,
1133
+
1134
+ //CSS
1135
+ accentColor: accentColor, // default to #15212A
1136
+ adjustedAccentColor: adjustedAccentColor || '#3498db', // default to #3498db
1137
+ adjustedAccentContrastColor: adjustedAccentContrastColor || '#ffffff', // default to #ffffff
1138
+ showBadge: newsletter.get('show_badge'),
1139
+ backgroundColor: backgroundColor,
1140
+ backgroundIsDark: backgroundIsDark,
1141
+ borderColor: borderColor,
1142
+ secondaryBorderColor: secondaryBorderColor,
1143
+ titleColor: titleColor,
1144
+ textColor: textColor,
1145
+ secondaryTextColor: secondaryTextColor,
1146
+ linkColor: linkColor,
1147
+ buttonBorderRadius,
1148
+
1149
+ headerImage,
1150
+ headerImageWidth,
1151
+ showHeaderIcon: newsletter.get('show_header_icon') && this.#settingsCache.get('icon'),
1152
+
1153
+ // TODO: consider moving these to newsletter property
1154
+ showHeaderTitle: newsletter.get('show_header_title'),
1155
+ showHeaderName: newsletter.get('show_header_name'),
1156
+ showFeatureImage: newsletter.get('show_feature_image') && !!postFeatureImage,
1157
+ footerContent: newsletter.get('footer_content'),
1158
+ hasOutlineButtons,
1159
+
1160
+ classes: {
1161
+ title: 'post-title' + ` ` + (post.get('custom_excerpt') ? 'post-title-with-excerpt' : 'post-title-no-excerpt') + (newsletter.get('title_font_category') === 'serif' ? ` post-title-serif` : ``) + (newsletter.get('title_alignment') === 'left' ? ` post-title-left` : ``),
1162
+ titleLink: 'post-title-link' + (newsletter.get('title_alignment') === 'left' ? ` post-title-link-left` : ``),
1163
+ excerpt: 'post-excerpt' + ` ` + (newsletter.get('show_feature_image') && !!postFeatureImage ? 'post-excerpt-with-feature-image' : 'post-excerpt-no-feature-image') + ` ` + excerptFontClass + (newsletter.get('title_alignment') === 'left' ? ` post-excerpt-left` : ``),
1164
+ meta: 'post-meta' + (newsletter.get('title_alignment') === 'left' ? ` post-meta-left` : ` post-meta-center`),
1165
+ body: newsletter.get('body_font_category') === 'sans_serif' ? `post-content-sans-serif` : `post-content`
1166
+ },
1167
+
1168
+ // Audience feedback
1169
+ feedbackButtons: newsletter.get('feedback_enabled') ? {
1170
+ likeHref: positiveLink,
1171
+ dislikeHref: negativeLink
1172
+ } : null,
1173
+
1174
+ // Paywall
1175
+ paywall: addPaywall ? {
1176
+ signupUrl: signupUrl.href
1177
+ } : null,
1178
+
1179
+ year: new Date().getFullYear().toString()
1180
+ };
1181
+
1182
+ return data;
1183
+ }
1184
+
1185
+ /**
1186
+ * @private
1187
+ * Sets and limits the width of an image + returns the width
1188
+ * @returns {Promise<{href: string, width: number, height: number | null}>}
1189
+ */
1190
+ async limitImageWidth(href, visibleWidth = 600, visibleHeight = null) {
1191
+ if (!href) {
1192
+ return {
1193
+ href,
1194
+ width: 0,
1195
+ height: null
1196
+ };
1197
+ }
1198
+ if (isUnsplashImage(href)) {
1199
+ // Unsplash images have a minimum size so assuming 1200px is safe
1200
+ const unsplashUrl = new URL(href);
1201
+ unsplashUrl.searchParams.delete('w');
1202
+ unsplashUrl.searchParams.delete('h');
1203
+
1204
+ unsplashUrl.searchParams.set('w', (visibleWidth * 2).toFixed(0));
1205
+
1206
+ if (visibleHeight) {
1207
+ unsplashUrl.searchParams.set('h', (visibleHeight * 2).toFixed(0));
1208
+ unsplashUrl.searchParams.set('fit', 'crop');
1209
+ }
1210
+
1211
+ return {
1212
+ href: unsplashUrl.href,
1213
+ width: visibleWidth,
1214
+ height: visibleHeight
1215
+ };
1216
+ } else {
1217
+ try {
1218
+ const size = await this.#imageSize.getImageSizeFromUrl(href);
1219
+
1220
+ if (size.width >= visibleWidth) {
1221
+ if (!visibleHeight) {
1222
+ // Keep aspect ratio
1223
+ size.height = Math.round(size.height * (visibleWidth / size.width));
1224
+ }
1225
+
1226
+ // keep original image, just set a fixed width
1227
+ size.width = visibleWidth;
1228
+ }
1229
+
1230
+ if (visibleHeight && size.height >= visibleHeight) {
1231
+ // keep original image, just set a fixed width
1232
+ size.height = visibleHeight;
1233
+ }
1234
+
1235
+ if (this.#storageUtils.isLocalImage(href)) {
1236
+ // we can safely request a 1200px image - Ghost will serve the original if it's smaller
1237
+ return {
1238
+ href: href.replace(/\/content\/images\//, '/content/images/size/w' + (visibleWidth * 2) + (visibleHeight ? 'h' + (visibleHeight * 2) : '') + '/'),
1239
+ width: size.width,
1240
+ height: size.height
1241
+ };
1242
+ }
1243
+
1244
+ return {
1245
+ href,
1246
+ width: size.width,
1247
+ height: size.height
1248
+ };
1249
+ } catch (err) {
1250
+ // log and proceed. Using original header image without fixed width isn't fatal.
1251
+ logging.error(err);
1252
+ }
1253
+ }
1254
+
1255
+ return {
1256
+ href,
1257
+ width: 0,
1258
+ height: null
1259
+ };
1260
+ }
1261
+ }
1262
+
1263
+ module.exports = EmailRenderer;