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.
- package/components/{tryghost-adapter-cache-memory-ttl-5.37.0.tgz → tryghost-adapter-cache-memory-ttl-5.38.0.tgz} +0 -0
- package/components/tryghost-adapter-cache-redis-5.38.0.tgz +0 -0
- package/components/{tryghost-adapter-manager-5.37.0.tgz → tryghost-adapter-manager-5.38.0.tgz} +0 -0
- package/components/{tryghost-api-framework-5.37.0.tgz → tryghost-api-framework-5.38.0.tgz} +0 -0
- package/components/{tryghost-api-version-compatibility-service-5.37.0.tgz → tryghost-api-version-compatibility-service-5.38.0.tgz} +0 -0
- package/components/tryghost-audience-feedback-5.38.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.38.0.tgz +0 -0
- package/components/{tryghost-constants-5.37.0.tgz → tryghost-constants-5.38.0.tgz} +0 -0
- package/components/tryghost-custom-theme-settings-service-5.38.0.tgz +0 -0
- package/components/{tryghost-data-generator-5.37.0.tgz → tryghost-data-generator-5.38.0.tgz} +0 -0
- package/components/tryghost-domain-events-5.38.0.tgz +0 -0
- package/components/tryghost-dynamic-routing-events-5.38.0.tgz +0 -0
- package/components/tryghost-email-analytics-provider-mailgun-5.38.0.tgz +0 -0
- package/components/{tryghost-email-analytics-service-5.37.0.tgz → tryghost-email-analytics-service-5.38.0.tgz} +0 -0
- package/components/tryghost-email-content-generator-5.38.0.tgz +0 -0
- package/components/tryghost-email-events-5.38.0.tgz +0 -0
- package/components/tryghost-email-service-5.38.0.tgz +0 -0
- package/components/{tryghost-email-suppression-list-5.37.0.tgz → tryghost-email-suppression-list-5.38.0.tgz} +0 -0
- package/components/tryghost-event-aware-cache-wrapper-5.38.0.tgz +0 -0
- package/components/{tryghost-express-dynamic-redirects-5.37.0.tgz → tryghost-express-dynamic-redirects-5.38.0.tgz} +0 -0
- package/components/tryghost-external-media-inliner-5.38.0.tgz +0 -0
- package/components/tryghost-extract-api-key-5.38.0.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.38.0.tgz +0 -0
- package/components/tryghost-i18n-5.38.0.tgz +0 -0
- package/components/{tryghost-importer-handler-content-files-5.37.0.tgz → tryghost-importer-handler-content-files-5.38.0.tgz} +0 -0
- package/components/tryghost-importer-revue-5.38.0.tgz +0 -0
- package/components/tryghost-job-manager-5.38.0.tgz +0 -0
- package/components/tryghost-link-redirects-5.38.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.38.0.tgz +0 -0
- package/components/{tryghost-link-tracking-5.37.0.tgz → tryghost-link-tracking-5.38.0.tgz} +0 -0
- package/components/{tryghost-magic-link-5.37.0.tgz → tryghost-magic-link-5.38.0.tgz} +0 -0
- package/components/tryghost-mailgun-client-5.38.0.tgz +0 -0
- package/components/tryghost-member-attribution-5.38.0.tgz +0 -0
- package/components/tryghost-member-events-5.38.0.tgz +0 -0
- package/components/tryghost-members-api-5.38.0.tgz +0 -0
- package/components/tryghost-members-csv-5.38.0.tgz +0 -0
- package/components/{tryghost-members-events-service-5.37.0.tgz → tryghost-members-events-service-5.38.0.tgz} +0 -0
- package/components/{tryghost-members-importer-5.37.0.tgz → tryghost-members-importer-5.38.0.tgz} +0 -0
- package/components/tryghost-members-offers-5.38.0.tgz +0 -0
- package/components/tryghost-members-payments-5.38.0.tgz +0 -0
- package/components/tryghost-members-ssr-5.38.0.tgz +0 -0
- package/components/{tryghost-members-stripe-service-5.37.0.tgz → tryghost-members-stripe-service-5.38.0.tgz} +0 -0
- package/components/tryghost-milestones-5.38.0.tgz +0 -0
- package/components/tryghost-minifier-5.38.0.tgz +0 -0
- package/components/tryghost-mw-api-version-mismatch-5.38.0.tgz +0 -0
- package/components/{tryghost-mw-cache-control-5.37.0.tgz → tryghost-mw-cache-control-5.38.0.tgz} +0 -0
- package/components/tryghost-mw-error-handler-5.38.0.tgz +0 -0
- package/components/tryghost-mw-session-from-token-5.38.0.tgz +0 -0
- package/components/tryghost-mw-update-user-last-seen-5.38.0.tgz +0 -0
- package/components/tryghost-mw-version-match-5.38.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.38.0.tgz +0 -0
- package/components/tryghost-oembed-service-5.38.0.tgz +0 -0
- package/components/tryghost-package-json-5.38.0.tgz +0 -0
- package/components/{tryghost-referrers-5.37.0.tgz → tryghost-referrers-5.38.0.tgz} +0 -0
- package/components/tryghost-security-5.38.0.tgz +0 -0
- package/components/tryghost-session-service-5.38.0.tgz +0 -0
- package/components/tryghost-settings-path-manager-5.38.0.tgz +0 -0
- package/components/tryghost-slack-notifications-5.38.0.tgz +0 -0
- package/components/tryghost-staff-service-5.38.0.tgz +0 -0
- package/components/tryghost-stats-service-5.38.0.tgz +0 -0
- package/components/tryghost-tiers-5.38.0.tgz +0 -0
- package/components/{tryghost-update-check-service-5.37.0.tgz → tryghost-update-check-service-5.38.0.tgz} +0 -0
- package/components/tryghost-verification-trigger-5.38.0.tgz +0 -0
- package/components/{tryghost-version-notifications-data-service-5.37.0.tgz → tryghost-version-notifications-data-service-5.38.0.tgz} +0 -0
- package/components/tryghost-webmentions-5.38.0.tgz +0 -0
- package/core/boot.js +11 -4
- package/core/built/admin/assets/{chunk.143.27cd10a38f877e715b35.js → chunk.143.c6802c882a911797ce4f.js} +6 -6
- package/core/built/admin/assets/{chunk.178.dd6cf17fb0986acf19d6.js → chunk.178.09faefd4027fcba4113d.js} +4 -4
- package/core/built/admin/assets/{chunk.652.bb618bc5abf23bed4e87.js → chunk.220.9ca2950240aba3fced21.js} +1836 -1774
- package/core/built/admin/assets/{chunk.79.4a959c324df25480b90e.js → chunk.79.acb7dd01e1c785f4920c.js} +12 -11
- package/core/built/admin/assets/{ghost-2948791640be026b987b88f89034bc85.js → ghost-35103ff053c43f1dfa7f35821c3c2412.js} +29 -29
- package/core/built/admin/assets/{ghost-efbe4dcc249d119a955b038aae5c980d.css → ghost-a9307c9cfe26a4bc621e02cd3bae421a.css} +1 -1
- package/core/built/admin/assets/{ghost-dark-6ea4b338f17a43c204b7c1e207b90cd7.css → ghost-dark-f309cf445255344e4861a95ecb8f1920.css} +1 -1
- package/core/built/admin/assets/vendor-b982e3bf1020bff77b2a3c44d5f59e55.js +269 -269
- package/core/built/admin/index.html +5 -5
- package/core/frontend/helpers/ghost_head.js +4 -1
- package/core/frontend/services/routing/StaticPagesRouter.js +1 -1
- package/core/frontend/services/sitemap/base-generator.js +5 -1
- package/core/server/adapters/storage/LocalImagesStorage.js +1 -1
- package/core/server/api/endpoints/email-previews.js +2 -43
- package/core/server/api/endpoints/emails.js +1 -22
- package/core/server/api/endpoints/utils/serializers/output/mappers/emails.js +14 -8
- package/core/server/data/importer/import-manager.js +8 -1
- package/core/server/data/migrations/versions/4.9/05-fix-missed-mobiledoc-url-transforms.js +1 -1
- package/core/server/lib/common/events.js +16 -23
- package/core/server/models/base/plugins/relations.js +5 -3
- package/core/server/models/index.js +5 -0
- package/core/server/services/comments/emails.js +2 -2
- package/core/server/services/email-service/wrapper.js +2 -0
- package/core/server/services/link-tracking/LinkClickRepository.js +1 -1
- package/core/server/services/media-inliner/service.js +49 -3
- package/core/server/services/mentions/service.js +6 -1
- package/core/server/services/posts/posts-service.js +3 -14
- package/core/server/services/staff/index.js +2 -0
- package/core/server/services/url/Urls.js +10 -2
- package/core/shared/labs.js +0 -1
- package/package.json +138 -138
- package/yarn.lock +267 -259
- package/components/tryghost-adapter-cache-redis-5.37.0.tgz +0 -0
- package/components/tryghost-audience-feedback-5.37.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.37.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.37.0.tgz +0 -0
- package/components/tryghost-domain-events-5.37.0.tgz +0 -0
- package/components/tryghost-dynamic-routing-events-5.37.0.tgz +0 -0
- package/components/tryghost-email-analytics-provider-mailgun-5.37.0.tgz +0 -0
- package/components/tryghost-email-content-generator-5.37.0.tgz +0 -0
- package/components/tryghost-email-events-5.37.0.tgz +0 -0
- package/components/tryghost-email-service-5.37.0.tgz +0 -0
- package/components/tryghost-event-aware-cache-wrapper-5.37.0.tgz +0 -0
- package/components/tryghost-external-media-inliner-5.37.0.tgz +0 -0
- package/components/tryghost-extract-api-key-5.37.0.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.37.0.tgz +0 -0
- package/components/tryghost-i18n-5.37.0.tgz +0 -0
- package/components/tryghost-importer-revue-5.37.0.tgz +0 -0
- package/components/tryghost-job-manager-5.37.0.tgz +0 -0
- package/components/tryghost-link-redirects-5.37.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.37.0.tgz +0 -0
- package/components/tryghost-mailgun-client-5.37.0.tgz +0 -0
- package/components/tryghost-member-attribution-5.37.0.tgz +0 -0
- package/components/tryghost-member-events-5.37.0.tgz +0 -0
- package/components/tryghost-members-api-5.37.0.tgz +0 -0
- package/components/tryghost-members-csv-5.37.0.tgz +0 -0
- package/components/tryghost-members-offers-5.37.0.tgz +0 -0
- package/components/tryghost-members-payments-5.37.0.tgz +0 -0
- package/components/tryghost-members-ssr-5.37.0.tgz +0 -0
- package/components/tryghost-milestones-5.37.0.tgz +0 -0
- package/components/tryghost-minifier-5.37.0.tgz +0 -0
- package/components/tryghost-mw-api-version-mismatch-5.37.0.tgz +0 -0
- package/components/tryghost-mw-error-handler-5.37.0.tgz +0 -0
- package/components/tryghost-mw-session-from-token-5.37.0.tgz +0 -0
- package/components/tryghost-mw-update-user-last-seen-5.37.0.tgz +0 -0
- package/components/tryghost-mw-version-match-5.37.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.37.0.tgz +0 -0
- package/components/tryghost-oembed-service-5.37.0.tgz +0 -0
- package/components/tryghost-package-json-5.37.0.tgz +0 -0
- package/components/tryghost-security-5.37.0.tgz +0 -0
- package/components/tryghost-session-service-5.37.0.tgz +0 -0
- package/components/tryghost-settings-path-manager-5.37.0.tgz +0 -0
- package/components/tryghost-slack-notifications-5.37.0.tgz +0 -0
- package/components/tryghost-staff-service-5.37.0.tgz +0 -0
- package/components/tryghost-stats-service-5.37.0.tgz +0 -0
- package/components/tryghost-tiers-5.37.0.tgz +0 -0
- package/components/tryghost-verification-trigger-5.37.0.tgz +0 -0
- package/components/tryghost-webmentions-5.37.0.tgz +0 -0
- package/core/server/services/bulk-email/bulk-email-processor.js +0 -289
- package/core/server/services/bulk-email/index.js +0 -1
- package/core/server/services/mega/email-preview.js +0 -54
- package/core/server/services/mega/feedback-buttons.js +0 -66
- package/core/server/services/mega/index.js +0 -14
- package/core/server/services/mega/mega.js +0 -626
- package/core/server/services/mega/post-email-serializer.js +0 -559
- package/core/server/services/mega/segment-parser.js +0 -20
- package/core/server/services/mega/template.js +0 -1319
- /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*?)(?:,? *(?:"|")(?<fallback>.*?)(?:"|"))?\}/;
|
|
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*?)(?:,? *(?:"|")(?<fallback>.*?)(?:"|"))?\}/;
|
|
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;
|