/* eslint-disable no-unused-vars */ const logging = require('@tryghost/logging'); const fs = require('fs').promises; const path = require('path'); const {isUnsplashImage, isLocalContentImage} = require('@tryghost/kg-default-cards/lib/utils'); const {Color, textColorForBackgroundColor, darkenToContrastThreshold} = require('@tryghost/color-utils'); const {DateTime} = require('luxon'); const htmlToPlaintext = require('@tryghost/html-to-plaintext'); /** * @typedef {string|null} Segment * @typedef {object} Post * @typedef {object} Newsletter */ /** * @typedef {object} MemberLike * @prop {string} id * @prop {string} uuid * @prop {string} email * @prop {string} name */ /** * @typedef {object} ReplacementDefinition * @prop {string} id * @prop {RegExp} token * @prop {(member: MemberLike) => string} getValue */ /** * @typedef {object} EmailRenderOptions * @prop {boolean} clickTrackingEnabled */ /** * @typedef {object} EmailBody * @prop {string} html * @prop {string} plaintext * @prop {ReplacementDefinition[]} replacements */ class EmailRenderer { #settingsCache; #settingsHelpers; #renderers; #imageSize; #urlUtils; #getPostUrl; #storageUtils; #handlebars; #renderTemplate; #linkReplacer; #linkTracking; #memberAttributionService; #audienceFeedbackService; /** * @param {object} dependencies * @param {object} dependencies.settingsCache * @param {{getNoReplyAddress(): string, getMembersSupportAddress(): string}} dependencies.settingsHelpers * @param {object} dependencies.renderers * @param {{render(object, options): string}} dependencies.renderers.lexical * @param {{render(object, options): string}} dependencies.renderers.mobiledoc * @param {{getImageSizeFromUrl(url: string): Promise<{width: number}>}} dependencies.imageSize * @param {{urlFor(type: string, optionsOrAbsolute, absolute): string, isSiteUrl(url, context): boolean}} dependencies.urlUtils * @param {{isLocalImage(url: string): boolean}} dependencies.storageUtils * @param {(post: Post) => string} dependencies.getPostUrl * @param {object} dependencies.linkReplacer * @param {object} dependencies.linkTracking * @param {object} dependencies.memberAttributionService * @param {object} dependencies.audienceFeedbackService */ constructor({ settingsCache, settingsHelpers, renderers, imageSize, urlUtils, storageUtils, getPostUrl, linkReplacer, linkTracking, memberAttributionService, audienceFeedbackService }) { this.#settingsCache = settingsCache; this.#settingsHelpers = settingsHelpers; this.#renderers = renderers; this.#imageSize = imageSize; this.#urlUtils = urlUtils; this.#storageUtils = storageUtils; this.#getPostUrl = getPostUrl; this.#linkReplacer = linkReplacer; this.#linkTracking = linkTracking; this.#memberAttributionService = memberAttributionService; this.#audienceFeedbackService = audienceFeedbackService; } getSubject(post) { return post.related('posts_meta')?.get('email_subject') || post.get('title'); } getFromAddress(_post, newsletter) { let senderName = this.#settingsCache.get('title') ? this.#settingsCache.get('title').replace(/"/g, '\\"') : ''; if (newsletter.get('sender_name')) { senderName = newsletter.get('sender_name'); } let fromAddress = this.#settingsHelpers.getNoReplyAddress(); if (newsletter.get('sender_email')) { fromAddress = newsletter.get('sender_email'); } // For local development, rewrite the fromAddress to a proper domain if (process.env.NODE_ENV !== 'production') { if (/@localhost$/.test(fromAddress) || /@ghost.local$/.test(fromAddress)) { const localAddress = 'localhost@example.com'; logging.warn(`Rewriting bulk email from address ${fromAddress} to ${localAddress}`); fromAddress = localAddress; } } return senderName ? `"${senderName}" <${fromAddress}>` : fromAddress; } /** * @param {Post} post * @param {Newsletter} newsletter * @returns {string|null} */ getReplyToAddress(post, newsletter) { if (newsletter.get('sender_reply_to') === 'support') { return this.#settingsHelpers.getMembersSupportAddress(); } return this.getFromAddress(post, newsletter); } /** Returns all the segments that we need to render the email for because they have different content. 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. @param {Post} post @returns {Segment[]} */ getSegments(post) { const allowedSegments = ['status:free', 'status:-free']; const html = this.renderPostBaseHtml(post); /** * Always add free and paid segments if email has paywall card */ if (html.indexOf('') !== -1) { // We have different content between free and paid members return allowedSegments; } const cheerio = require('cheerio'); const $ = cheerio.load(html); let allSegments = $('[data-gh-segment]') .get() .map(el => el.attribs['data-gh-segment']); const segments = [...new Set(allSegments)].filter(segment => allowedSegments.includes(segment)); if (segments.length === 0) { // No difference in email content between free and paid return [null]; } // We have different content between free and paid members return allowedSegments; } renderPostBaseHtml(post) { const postUrl = this.#getPostUrl(post); let html; if (post.get('lexical')) { html = this.#renderers.lexical.render( post.get('lexical'), {target: 'email', postUrl} ); } else { html = this.#renderers.mobiledoc.render( JSON.parse(post.get('mobiledoc')), {target: 'email', postUrl} ); } return html; } /** * * @param {Post} post * @param {Newsletter} newsletter * @param {Segment} segment * @param {EmailRenderOptions} options * @returns {Promise} */ async renderBody(post, newsletter, segment, options) { let html = this.renderPostBaseHtml(post); // We don't allow the usage of the %%{uuid}%% replacement in the email body (only in links and special cases) // So we need to filter them before we introduce the real %%{uuid}%% html = html.replace(/%%{uuid}%%/g, '{uuid}'); // Paywall and members only content handling const isPaidPost = post.get('visibility') === 'paid' || post.get('visibility') === 'tiers'; const membersOnlyIndex = html.indexOf(''); const hasMembersOnlyContent = membersOnlyIndex !== -1; let addPaywall = false; if (isPaidPost && hasMembersOnlyContent) { if (segment === 'status:free') { // Add paywall addPaywall = true; // Remove the members-only content html = html.slice(0, membersOnlyIndex); } } const templateData = await this.getTemplateData({ post, newsletter, html, addPaywall }); html = await this.renderTemplate(templateData); // Link tracking if (options.clickTrackingEnabled) { html = await this.#linkReplacer.replace(html, async (url) => { // We ignore all links that contain %%{uuid}%% // because otherwise we would add tracking to links that need to be replaced first if (url.toString().indexOf('%%{uuid}%%') !== -1) { return url.toString(); } // Add newsletter source attribution const isSite = this.#urlUtils.isSiteUrl(url); if (isSite) { // Add newsletter name as ref to the URL url = this.#memberAttributionService.addOutboundLinkTagging(url, newsletter); // Only add post attribution to our own site (because external sites could/should not process this information) url = this.#memberAttributionService.addPostAttributionTracking(url, post); } else { // Add email source attribution without the newsletter name url = this.#memberAttributionService.addOutboundLinkTagging(url); } // Add link click tracking url = await this.#linkTracking.service.addTrackingToUrl(url, post, '--uuid--'); // We need to convert to a string at this point, because we need invalid string characters in the URL const str = url.toString().replace(/--uuid--/g, '%%{uuid}%%'); return str; }); } // Juice HTML (inline CSS) const juice = require('juice'); html = juice(html, {inlinePseudoElements: true}); // happens after inlining of CSS so we can change element types without worrying about styling const cheerio = require('cheerio'); const $ = cheerio.load(html); // force all links to open in new tab $('a').attr('target', '_blank'); // convert figure and figcaption to div so that Outlook applies margins $('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div')); // Remove/hide parts of the email based on segment data attributes $('[data-gh-segment]').get().forEach((node) => { // TODO: replace with NQL interpretation if (node.attribs['data-gh-segment'] !== segment) { $(node).remove(); } else { // Getting rid of the attribute for a cleaner html output $(node).removeAttr('data-gh-segment'); } }); // Convert DOM back to HTML html = $.html(); // () Fix for vscode syntax highlighter // Replacement strings const replacementDefinitions = this.buildReplacementDefinitions({html, newsletter}); // TODO: normalizeReplacementStrings (replace unsupported replacement strings) // Convert HTML to plaintext const plaintext = htmlToPlaintext.email(html); // Fix any unsupported chars in Outlook html = html.replace(/'/g, '''); html = html.replace(/→/g, '→'); html = html.replace(/–/g, '–'); html = html.replace(/“/g, '“'); html = html.replace(/”/g, '”'); return { html, plaintext, replacements: replacementDefinitions }; } /** * @private * createUnsubscribeUrl * * Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe * In case of no member uuid, generates the preview unsubscribe url - `?preview=1` * * @param {string} [uuid] post uuid * @param {Object} [options] * @param {string} [options.newsletterUuid] newsletter uuid * @param {boolean} [options.comments] Unsubscribe from comment emails */ createUnsubscribeUrl(uuid, options = {}) { const siteUrl = this.#urlUtils.urlFor('home', true); const unsubscribeUrl = new URL(siteUrl); unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/'); if (uuid) { unsubscribeUrl.searchParams.set('uuid', uuid); } else { unsubscribeUrl.searchParams.set('preview', '1'); } if (options.newsletterUuid) { unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid); } if (options.comments) { unsubscribeUrl.searchParams.set('comments', '1'); } return unsubscribeUrl.href; } /** * @private * Note that we only look in HTML because plaintext and HTML are essentially the same content * @returns {ReplacementDefinition[]} */ buildReplacementDefinitions({html, newsletter}) { const baseDefinitions = [ { id: 'unsubscribe_url', getValue: (member) => { return this.createUnsubscribeUrl(member.uuid, {newsletterUuid: newsletter.get('uuid')}); } }, { id: 'uuid', getValue: (member) => { return member.uuid; } }, { id: 'first_name', getValue: (member) => { return member.name?.split(' ')[0]; } } ]; // Now loop through all the definenitions to see which ones are actually used + to add fallbacks if needed const EMAIL_REPLACEMENT_REGEX = /%%\{(.*?)\}%%/g; const REPLACEMENT_STRING_REGEX = /^(?\w+?)(?:,? *(?:"|")(?.*?)(?:"|"))?$/; function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Stores the definitions that we are actually going to use const replacements = []; let result; while ((result = EMAIL_REPLACEMENT_REGEX.exec(html)) !== null) { const [replacementMatch, replacementStr] = result; // Did we already found this match and added it to the replacements array? if (replacements.find(r => r.id === replacementStr)) { continue; } const match = replacementStr.match(REPLACEMENT_STRING_REGEX); if (match) { const {recipientProperty, fallback} = match.groups; const definition = baseDefinitions.find(d => d.id === recipientProperty); if (definition) { replacements.push({ id: replacementStr, originalId: recipientProperty, token: new RegExp(escapeRegExp(replacementMatch), 'g'), getValue: fallback ? (member => definition.getValue(member) || fallback) : definition.getValue }); } } } // Now loop any replacements with possible invalid characters and replace them with a clean id let counter = 1; for (const replacement of replacements) { if (replacement.id.match(/[^a-zA-Z0-9_]/)) { counter += 1; replacement.id = replacement.originalId + '_' + counter; } delete replacement.originalId; } return replacements; } async renderTemplate(data) { if (this.#renderTemplate) { return this.#renderTemplate(data); } this.#handlebars = require('handlebars'); // Helpers this.#handlebars.registerHelper('if', function (conditional, options) { if (conditional) { return options.fn(this); } else { return options.inverse(this); } }); this.#handlebars.registerHelper('and', function () { const len = arguments.length - 1; for (let i = 0; i < len; i++) { if (!arguments[i]) { return false; } } return true; }); this.#handlebars.registerHelper('not', function () { const len = arguments.length - 1; for (let i = 0; i < len; i++) { if (!arguments[i]) { return true; } } return false; }); this.#handlebars.registerHelper('or', function () { const len = arguments.length - 1; for (let i = 0; i < len; i++) { if (arguments[i]) { return true; } } return false; }); // Partials const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles.hbs`), 'utf8'); this.#handlebars.registerPartial('styles', cssPartialSource); const paywallPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `paywall.hbs`), 'utf8'); this.#handlebars.registerPartial('paywall', paywallPartial); const feedbackButtonPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `feedback-button.hbs`), 'utf8'); this.#handlebars.registerPartial('feedbackButton', feedbackButtonPartial); // Actual template const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template.hbs`), 'utf8'); this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString()); return this.#renderTemplate(data); } /** * Get email preheader text from post model * @param {object} postModel * @returns */ #getEmailPreheader(postModel) { let plaintext = postModel.get('plaintext'); let customExcerpt = postModel.get('custom_excerpt'); if (customExcerpt) { return customExcerpt; } else { if (plaintext) { return plaintext.substring(0, 500); } else { return `${postModel.get('title')} – `; } } } /** * @private */ async getTemplateData({post, newsletter, html, addPaywall}) { let accentColor = this.#settingsCache.get('accent_color') || '#15212A'; let adjustedAccentColor; let adjustedAccentContrastColor; try { adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex(); adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex(); } catch (e) { logging.error(e); accentColor = '#15212A'; } const {href: headerImage, width: headerImageWidth} = await this.limitImageWidth(newsletter.get('header_image')); const {href: postFeatureImage, width: postFeatureImageWidth} = await this.limitImageWidth(post.get('feature_image')); const timezone = this.#settingsCache.get('timezone'); const publishedAt = (post.get('published_at') ? DateTime.fromJSDate(post.get('published_at')) : DateTime.local()).setZone(timezone).setLocale('en-gb').toLocaleString({ year: 'numeric', month: 'short', day: 'numeric' }); let authors; const postAuthors = await post.getLazyRelation('authors'); if (postAuthors?.models) { if (postAuthors.models.length <= 2) { authors = postAuthors.models.map(author => author.get('name')).join(' & '); } else { authors = `${postAuthors.models[0].get('name')} & ${postAuthors.models.length - 1} others`; } } const postUrl = this.#getPostUrl(post); // Signup URL is the post url with a hash added to it const signupUrl = new URL(postUrl); signupUrl.hash = `/portal/signup`; // Audience feedback const positiveLink = this.#audienceFeedbackService.buildLink( '--uuid--', post.id, 1 ).href.replace('--uuid--', '%%{uuid}%%'); const negativeLink = this.#audienceFeedbackService.buildLink( '--uuid--', post.id, 0 ).href.replace('--uuid--', '%%{uuid}%%'); const data = { site: { title: this.#settingsCache.get('title'), url: this.#urlUtils.urlFor('home', true), iconUrl: this.#settingsCache.get('icon') ? this.#urlUtils.urlFor('image', { image: this.#settingsCache.get('icon') }, true) : null }, preheader: this.#getEmailPreheader(post), html, post: { title: post.get('title'), url: postUrl, authors, publishedAt, feature_image: postFeatureImage, feature_image_width: postFeatureImageWidth, feature_image_alt: post.related('posts_meta')?.get('feature_image_alt'), feature_image_caption: post.related('posts_meta')?.get('feature_image_caption') }, newsletter: { name: newsletter.get('name') }, //CSS accentColor: accentColor, // default to #15212A adjustedAccentColor: adjustedAccentColor || '#3498db', // default to #3498db adjustedAccentContrastColor: adjustedAccentContrastColor || '#ffffff', // default to #ffffff showBadge: newsletter.get('show_badge'), headerImage, headerImageWidth, showHeaderIcon: newsletter.get('show_header_icon') && this.#settingsCache.get('icon'), showHeaderTitle: newsletter.get('show_header_title'), showHeaderName: newsletter.get('show_header_name'), showFeatureImage: newsletter.get('show_feature_image') && !!postFeatureImage, footerContent: newsletter.get('footer_content'), classes: { title: 'post-title' + (newsletter.get('title_font_category') === 'serif' ? ` post-title-serif` : ``) + (newsletter.get('title_alignment') === 'left' ? ` post-title-left` : ``), titleLink: 'post-title-link' + (newsletter.get('title_alignment') === 'left' ? ` post-title-link-left` : ``), meta: 'post-meta' + (newsletter.get('title_alignment') === 'left' ? ` post-meta-left` : ``), body: newsletter.get('body_font_category') === 'sans_serif' ? `post-content-sans-serif` : `post-content` }, // Audience feedback feedbackButtons: newsletter.get('feedback_enabled') ? { likeHref: positiveLink, dislikeHref: negativeLink } : null, // Paywall paywall: addPaywall ? { signupUrl: signupUrl.href } : null, year: new Date().getFullYear().toString() }; return data; } /** * @private * Sets and limits the width of an image + returns the width * @returns {Promise<{href: string, width: number}>} */ async limitImageWidth(href) { if (!href) { return { href, width: 0 }; } if (isUnsplashImage(href)) { // Unsplash images have a minimum size so assuming 1200px is safe const unsplashUrl = new URL(href); unsplashUrl.searchParams.set('w', '1200'); return { href: unsplashUrl.href, width: 600 }; } else { try { const size = await this.#imageSize.getImageSizeFromUrl(href); if (size.width >= 600) { // keep original image, just set a fixed width size.width = 600; } if (this.#storageUtils.isLocalImage(href)) { // we can safely request a 1200px image - Ghost will serve the original if it's smaller return { href: href.replace(/\/content\/images\//, '/content/images/size/w1200/'), width: size.width }; } return { href, width: size.width }; } catch (err) { // log and proceed. Using original header image without fixed width isn't fatal. logging.error(err); } } return { href, width: 0 }; } } module.exports = EmailRenderer;