diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 545faa8596..e5fdd0ecf8 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -312,6 +312,7 @@ async function initServices({config}) { permissions.init(), xmlrpc.listen(), slack.listen(), + audienceFeedback.init(), emailService.init(), mega.listen(), webhooks.listen(), @@ -322,7 +323,6 @@ async function initServices({config}) { }), comments.init(), linkTracking.init(), - audienceFeedback.init(), emailSuppressionList.init() ]); debug('End: Services'); diff --git a/ghost/core/core/server/services/email-service/wrapper.js b/ghost/core/core/server/services/email-service/wrapper.js index f2ec7e388f..84db4a708f 100644 --- a/ghost/core/core/server/services/email-service/wrapper.js +++ b/ghost/core/core/server/services/email-service/wrapper.js @@ -1,7 +1,14 @@ const logging = require('@tryghost/logging'); const ObjectID = require('bson-objectid').default; +const url = require('../../../server/api/endpoints/utils/serializers/output/utils/url'); class EmailServiceWrapper { + getPostUrl(post) { + const jsonModel = post.toJSON(); + url.forPost(post.id, jsonModel, {options: {}}); + return jsonModel.url; + } + init() { if (this.service) { return; @@ -10,6 +17,7 @@ class EmailServiceWrapper { const {EmailService, EmailController, EmailRenderer, SendingService, BatchSendingService, EmailSegmenter, EmailEventStorage} = require('@tryghost/email-service'); const {Post, Newsletter, Email, EmailBatch, EmailRecipient, Member} = require('../../models'); const settingsCache = require('../../../shared/settings-cache'); + const settingsHelpers = require('../../services/settings-helpers'); const jobsService = require('../jobs'); const membersService = require('../members'); const db = require('../../data/db'); @@ -17,11 +25,53 @@ class EmailServiceWrapper { const limitService = require('../limits'); const domainEvents = require('@tryghost/domain-events'); - const emailRenderer = new EmailRenderer(); + const mobiledocLib = require('../../lib/mobiledoc'); + const lexicalLib = require('../../lib/lexical'); + const urlUtils = require('../../../shared/url-utils'); + const memberAttribution = require('../member-attribution'); + const linkReplacer = require('@tryghost/link-replacer'); + const linkTracking = require('../link-tracking'); + const audienceFeedback = require('../audience-feedback'); + + const emailRenderer = new EmailRenderer({ + settingsCache, + settingsHelpers, + renderers: { + mobiledoc: mobiledocLib.mobiledocHtmlRenderer, + lexical: lexicalLib.lexicalHtmlRenderer + }, + imageSize: null, + urlUtils, + getPostUrl: this.getPostUrl, + linkReplacer, + linkTracking, + memberAttributionService: memberAttribution.service, + audienceFeedbackService: audienceFeedback.service + }); + const sendingService = new SendingService({ emailProvider: { - send: ({plaintext, subject, from, replyTo, recipients}) => { + send: async ({plaintext, subject, from, replyTo, recipients}) => { logging.info(`Sending email\nSubject: ${subject}\nFrom: ${from}\nReplyTo: ${replyTo}\nRecipients: ${recipients.length}\n\n${plaintext}`); + + // Uncomment to test email HTML rendering with GhostMailer + /*const {GhostMailer} = require('../mail'); + const mailer = new GhostMailer(); + logging.info(`Sending email\nSubject: ${subject}\nFrom: ${from}\nReplyTo: ${replyTo}\nRecipients: ${recipients.length}\n\n${JSON.stringify(recipients[0].replacements, undefined, ' ')}`); + + for (const replacement of recipients[0].replacements) { + html = html.replace(replacement.token, replacement.value); + plaintext = plaintext.replace(replacement.token, replacement.value); + } + + await mailer.send({ + subject, + html, + to: recipients[0].email, + from, + replyTo, + text: plaintext + });*/ return Promise.resolve({id: 'fake_provider_id_' + ObjectID().toHexString()}); } }, diff --git a/ghost/core/core/server/services/mega/feedback-buttons.js b/ghost/core/core/server/services/mega/feedback-buttons.js index f0ab67d05c..a0a25b0d9e 100644 --- a/ghost/core/core/server/services/mega/feedback-buttons.js +++ b/ghost/core/core/server/services/mega/feedback-buttons.js @@ -125,7 +125,13 @@ function getButtonLightTheme(accentColor) { }; } +/** + * @deprecated + * PLEASE MAKE IDENTICAL CHANGES TO email-service package email-templates/styles.hbs + */ function getButtonsHeadStyles() { + // DEPRECATED! + // PLEASE MAKE IDENTICAL CHANGES TO email-service package email-templates/styles.hbs return (` .like-icon { mix-blend-mode: darken; diff --git a/ghost/core/core/server/services/mega/template.js b/ghost/core/core/server/services/mega/template.js index fbdfb7cea7..63b4368616 100644 --- a/ghost/core/core/server/services/mega/template.js +++ b/ghost/core/core/server/services/mega/template.js @@ -1,3 +1,15 @@ +// --------------------------------------------- +// --------------------------------------------- +// +// WARNING!! +// +// THIS FILE IS DEPRECATED. PLEASE ALSO MAKE IDENTICAL CHANGES IN THE EMAIL-SERVICE PACKAGE -> email-templates/template.hbs +// +// WARNING!! +// +// --------------------------------------------- +// --------------------------------------------- + const {escapeHtml: escape} = require('@tryghost/string'); const feedbackButtons = require('./feedback-buttons'); diff --git a/ghost/core/test/unit/server/services/email-service/EmailServiceWrapper.test.js b/ghost/core/test/unit/server/services/email-service/EmailServiceWrapper.test.js index 92a5d9a634..5279370e0e 100644 --- a/ghost/core/test/unit/server/services/email-service/EmailServiceWrapper.test.js +++ b/ghost/core/test/unit/server/services/email-service/EmailServiceWrapper.test.js @@ -8,5 +8,12 @@ describe('EmailServiceWrapper', function () { const service = require('../../../../../core/server/services/email-service'); service.init(); + + // Increase test coverage for the wrapper + service.getPostUrl({ + toJSON: () => [{ + id: '1' + }] + }); }); }); diff --git a/ghost/email-service/lib/batch-sending-service.js b/ghost/email-service/lib/batch-sending-service.js index 9ce7710578..acc751af4e 100644 --- a/ghost/email-service/lib/batch-sending-service.js +++ b/ghost/email-service/lib/batch-sending-service.js @@ -114,7 +114,7 @@ class BatchSendingService { // Load required relations const newsletter = await email.getLazyRelation('newsletter', {require: true}); - const post = await email.getLazyRelation('post', {require: true}); + const post = await email.getLazyRelation('post', {require: true, withRelated: ['posts_meta']}); let batches = await this.getBatches(email); if (batches.length === 0) { @@ -142,7 +142,7 @@ class BatchSendingService { async createBatches({email, post, newsletter}) { logging.info(`Creating batches for email ${email.id}`); - const segments = await this.#emailRenderer.getSegments(post, newsletter); + const segments = this.#emailRenderer.getSegments(post); const batches = []; const BATCH_SIZE = 500; let totalCount = 0; diff --git a/ghost/email-service/lib/email-renderer.js b/ghost/email-service/lib/email-renderer.js index 86c55335e3..43394bfc81 100644 --- a/ghost/email-service/lib/email-renderer.js +++ b/ghost/email-service/lib/email-renderer.js @@ -1,5 +1,13 @@ /* 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 @@ -16,7 +24,8 @@ /** * @typedef {object} ReplacementDefinition - * @prop {string} token + * @prop {string} id + * @prop {RegExp} token * @prop {(member: MemberLike) => string} getValue */ @@ -33,14 +42,143 @@ */ class EmailRenderer { + #settingsCache; + #settingsHelpers; + + #renderers; + + #imageSize; + #urlUtils; + #getPostUrl; + + #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 {(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, + getPostUrl, + linkReplacer, + linkTracking, + memberAttributionService, + audienceFeedbackService + }) { + this.#settingsCache = settingsCache; + this.#settingsHelpers = settingsHelpers; + this.#renderers = renderers; + this.#imageSize = imageSize; + this.#urlUtils = urlUtils; + 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); + } + /** Not sure about this, but we need a method that can tell us which member segments are needed for a given post/email. @param {Post} post - @param {Newsletter} newsletter - @returns {Promise} + @returns {Segment[]} */ - async getSegments(post, newsletter) { - return [null]; + getSegments(post) { + const allowedSegments = ['status:free', 'status:-free']; + const html = this.renderPostBaseHtml(post); + + const cheerio = require('cheerio'); + const $ = cheerio.load(html); + + let allSegments = $('[data-gh-segment]') + .get() + .map(el => el.attribs['data-gh-segment']); + + /** + * Always add free and paid segments if email has paywall card + */ + if (html.indexOf('') !== -1) { + allSegments = allSegments.concat(['status:free', 'status:-free']); + } + + const segments = [...new Set(allSegments)].filter(segment => allowedSegments.includes(segment)); + if (segments.length === 0) { + // One segment to all members + return [null]; + } + return segments; + } + + renderPostBaseHtml(post) { + let html; + if (post.get('lexical')) { + html = this.#renderers.lexical.render( + post.get('lexical'), {target: 'email', postUrl: post.url} + ); + } else { + html = this.#renderers.mobiledoc.render( + JSON.parse(post.get('mobiledoc')), {target: 'email', postUrl: post.url} + ); + } + return html; } /** @@ -52,28 +190,462 @@ class EmailRenderer { * @returns {Promise} */ async renderBody(post, newsletter, segment, options) { + let html = this.renderPostBaseHtml(post); + + // 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.addEmailSourceAttributionTracking(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.addEmailSourceAttributionTracking(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: 'HTML', - plaintext: 'Plaintext', - replacements: [] + html, + plaintext, + replacements: replacementDefinitions }; } - getSubject(post, newsletter) { - return 'Subject'; - } + /** + * @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'); + } - getFromAddress(post, newsletter) { - return 'noreply@example.com'; // TODO + return unsubscribeUrl.href; } /** - * @param {Post} post - * @param {Newsletter} newsletter - * @returns {string|null} + * @private + * Note that we only look in HTML because plaintext and HTML are essentially the same content + * @returns {ReplacementDefinition[]} */ - getReplyToAddress(post, newsletter) { - return 'noreply@example.com'; // TODO + 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, + token: new RegExp(escapeRegExp(replacementMatch), 'g'), + getValue: fallback ? (member => definition.getValue(member) || fallback) : definition.getValue + }); + } + } + } + + 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); + } + + /** + * @private + */ + async getTemplateData({post, newsletter, html, addPaywall}) { + const accentColor = this.#settingsCache.get('accent_color') || '#15212A'; + const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex(); + const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex(); + + const color = new Color(accentColor); + const buttonBackgroundColor = `${accentColor}10`; + const buttonTextColor = color.darken(0.6).hex(); + + 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).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].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: post.get('excerpt') ? post.get('excerpt') : `${post.get('title')} – `, + 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, + backgroundColor: buttonBackgroundColor, + textColor: buttonTextColor, + + sizes: { + width: 100, + height: 38, + iconWidth: 24 + }, + // Sizes defined in pixels won’t be adjusted when Outlook is rendering at 120 dpi. + // To solve the problem we use values in points (1 pixel = 0.75 point). + // resource: https://www.hteumeuleu.com/2021/background-properties-in-vml/ + sizesOutlook: { + width: (100 + 24) * 0.75, + height: 38 * 0.75 + 1, + iconWidth: 24 * 0.75 + } + } : 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; + } + + // WARNING: + // TODO: this whole `isLocalContentImage` can never ever work (always false), this is old code that needs a rewrite! + if (isLocalContentImage(href, this.#urlUtils.urlFor('home', true))) { + // 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 + }; } } diff --git a/ghost/email-service/lib/email-service.js b/ghost/email-service/lib/email-service.js index c132c4a860..4dc37f2bd0 100644 --- a/ghost/email-service/lib/email-service.js +++ b/ghost/email-service/lib/email-service.js @@ -100,10 +100,12 @@ class EmailService { track_clicks: !!this.#settingsCache.get('email_track_clicks'), feedback_enabled: !!newsletter.get('feedback_enabled'), recipient_filter: emailRecipientFilter, - subject: this.#emailRenderer.getSubject(post, newsletter), + subject: this.#emailRenderer.getSubject(post), from: this.#emailRenderer.getFromAddress(post, newsletter), replyTo: this.#emailRenderer.getReplyToAddress(post, newsletter), - email_count: await this.#emailSegmenter.getMembersCount(newsletter, emailRecipientFilter) + email_count: await this.#emailSegmenter.getMembersCount(newsletter, emailRecipientFilter), + source: post.get('lexical') || post.get('mobiledoc'), + source_type: post.get('lexical') ? 'lexical' : 'mobiledoc' }); try { diff --git a/ghost/email-service/lib/email-templates/partials/feedback-button.hbs b/ghost/email-service/lib/email-templates/partials/feedback-button.hbs new file mode 100644 index 0000000000..b23570a51a --- /dev/null +++ b/ghost/email-service/lib/email-templates/partials/feedback-button.hbs @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/ghost/email-service/lib/email-templates/partials/paywall.hbs b/ghost/email-service/lib/email-templates/partials/paywall.hbs new file mode 100644 index 0000000000..0fceaf846a --- /dev/null +++ b/ghost/email-service/lib/email-templates/partials/paywall.hbs @@ -0,0 +1,27 @@ +
+
+

+ Subscribe to continue reading.

+

Become a paid member of {{site.title}} to get access to all + subscriber-only content.

+
+ + + + + + +
+ Subscribe + +
+
+

+
diff --git a/ghost/email-service/lib/email-templates/partials/styles.hbs b/ghost/email-service/lib/email-templates/partials/styles.hbs new file mode 100644 index 0000000000..d8619223fd --- /dev/null +++ b/ghost/email-service/lib/email-templates/partials/styles.hbs @@ -0,0 +1,1142 @@ + diff --git a/ghost/email-service/lib/email-templates/template.hbs b/ghost/email-service/lib/email-templates/template.hbs new file mode 100644 index 0000000000..75256097b6 --- /dev/null +++ b/ghost/email-service/lib/email-templates/template.hbs @@ -0,0 +1,177 @@ + + + + + + + {{post.title}} + {{>styles}} + + + {{preheader}} + + + + + + + + + + + + + diff --git a/ghost/email-service/lib/sending-service.js b/ghost/email-service/lib/sending-service.js index 78e6786715..42b377bbb6 100644 --- a/ghost/email-service/lib/sending-service.js +++ b/ghost/email-service/lib/sending-service.js @@ -36,7 +36,8 @@ /** * @typedef {object} Replacement - * @prop {string} token + * @prop {string} id + * @prop {RegExp} token * @prop {string} value */ @@ -82,7 +83,7 @@ class SendingService { const recipients = this.buildRecipients(members, emailBody.replacements); return await this.#emailProvider.send({ - subject: this.#emailRenderer.getSubject(post, newsletter), + subject: this.#emailRenderer.getSubject(post), from: this.#emailRenderer.getFromAddress(post, newsletter), replyTo: this.#emailRenderer.getReplyToAddress(post, newsletter) ?? undefined, html: emailBody.html, @@ -103,6 +104,7 @@ class SendingService { email: member.email, replacements: replacementDefinitions.map((def) => { return { + id: def.id, token: def.token, value: def.getValue(member) }; diff --git a/ghost/email-service/package.json b/ghost/email-service/package.json index 39220de558..b0f7373968 100644 --- a/ghost/email-service/package.json +++ b/ghost/email-service/package.json @@ -28,6 +28,12 @@ "@tryghost/tpl": "0.1.19", "bson-objectid": "2.0.4", "@tryghost/email-events": "0.0.0", - "moment-timezone": "0.5.23" + "moment-timezone": "0.5.23", + "handlebars": "4.7.7", + "@tryghost/kg-default-cards": "5.18.5", + "@tryghost/color-utils": "0.1.21", + "@tryghost/html-to-plaintext": "0.0.0", + "juice": "8.1.0", + "cheerio": "0.22.0" } } diff --git a/ghost/email-service/test/email-renderer.test.js b/ghost/email-service/test/email-renderer.test.js new file mode 100644 index 0000000000..0fcc78b4c1 --- /dev/null +++ b/ghost/email-service/test/email-renderer.test.js @@ -0,0 +1,92 @@ +const EmailRenderer = require('../lib/email-renderer'); +const assert = require('assert'); + +describe('Email renderer', function () { + describe('buildReplacementDefinitions', function () { + const emailRenderer = new EmailRenderer({ + urlUtils: { + urlFor: () => 'http://example.com' + } + }); + const newsletter = { + get: () => '123' + }; + const member = { + id: '456', + uuid: 'myuuid', + name: 'Test User', + email: 'test@example.com' + }; + + it('returns an empty list of replacemetns if none used', function () { + const html = 'Hello world'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletter}); + assert.equal(replacements.length, 0); + }); + + it('returns a replacement if it is used', function () { + const html = 'Hello world %%{uuid}%%'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletter}); + assert.equal(replacements.length, 1); + assert.equal(replacements[0].token.toString(), '/%%\\{uuid\\}%%/g'); + assert.equal(replacements[0].id, 'uuid'); + assert.equal(replacements[0].getValue(member), 'myuuid'); + }); + + it('returns a replacement only once if used multiple times', function () { + const html = 'Hello world %%{uuid}%% And %%{uuid}%%'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletter}); + assert.equal(replacements.length, 1); + assert.equal(replacements[0].token.toString(), '/%%\\{uuid\\}%%/g'); + assert.equal(replacements[0].id, 'uuid'); + assert.equal(replacements[0].getValue(member), 'myuuid'); + }); + + it('returns correct first name', function () { + const html = 'Hello %%{first_name}%%,'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletter}); + assert.equal(replacements.length, 1); + assert.equal(replacements[0].token.toString(), '/%%\\{first_name\\}%%/g'); + assert.equal(replacements[0].id, 'first_name'); + assert.equal(replacements[0].getValue(member), 'Test'); + }); + + it('supports fallback values', function () { + const html = 'Hey %%{first_name, "there"}%%,'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletter}); + assert.equal(replacements.length, 1); + assert.equal(replacements[0].token.toString(), '/%%\\{first_name, "there"\\}%%/g'); + assert.equal(replacements[0].id, 'first_name, "there"'); + assert.equal(replacements[0].getValue(member), 'Test'); + + // In case of empty name + assert.equal(replacements[0].getValue({name: ''}), 'there'); + }); + + it('supports combination of multiple fallback values', function () { + const html = 'Hey %%{first_name, "there"}%%, %%{first_name, "member"}%% %%{first_name}%% %%{first_name, "there"}%%'; + const replacements = emailRenderer.buildReplacementDefinitions({html, newsletter}); + assert.equal(replacements.length, 3); + assert.equal(replacements[0].token.toString(), '/%%\\{first_name, "there"\\}%%/g'); + assert.equal(replacements[0].id, 'first_name, "there"'); + assert.equal(replacements[0].getValue(member), 'Test'); + + // In case of empty name + assert.equal(replacements[0].getValue({name: ''}), 'there'); + + assert.equal(replacements[1].token.toString(), '/%%\\{first_name, "member"\\}%%/g'); + assert.equal(replacements[1].id, 'first_name, "member"'); + assert.equal(replacements[1].getValue(member), 'Test'); + + // In case of empty name + assert.equal(replacements[1].getValue({name: ''}), 'member'); + + assert.equal(replacements[2].token.toString(), '/%%\\{first_name\\}%%/g'); + assert.equal(replacements[2].id, 'first_name'); + assert.equal(replacements[2].getValue(member), 'Test'); + + // In case of empty name + assert.equal(replacements[2].getValue({name: ''}), ''); + }); + }); +}); diff --git a/ghost/email-service/test/hello.test.js b/ghost/email-service/test/hello.test.js deleted file mode 100644 index 85d69d1e08..0000000000 --- a/ghost/email-service/test/hello.test.js +++ /dev/null @@ -1,10 +0,0 @@ -// Switch these lines once there are useful utils -// const testUtils = require('./utils'); -require('./utils'); - -describe('Hello world', function () { - it('Runs a test', function () { - // TODO: Write me! - 'hello'.should.eql('hello'); - }); -});