0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Added email renderer implementation draft (#15877)

fixes https://github.com/TryGhost/Team/issues/2308

- Still has some missing pieces, but mostly works.
- Uses new handlebars template for emails
- When sending emails with the new email stability flag enabled, one
test email is now sent via the default smtp ghost mailer.
This commit is contained in:
Simon Backx 2022-11-29 11:27:17 +01:00 committed by GitHub
parent f4fdb4fa6c
commit f5045b9bf7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 2160 additions and 38 deletions

View file

@ -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');

View file

@ -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()});
}
},

View file

@ -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;

View file

@ -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');

View file

@ -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'
}]
});
});
});

View file

@ -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;

View file

@ -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<Segment[]>}
@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('<!--members-only-->') !== -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<EmailBody>}
*/
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('<!--members-only-->');
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(/&apos;/g, '&#39;');
html = html.replace(/→/g, '&rarr;');
html = html.replace(//g, '&ndash;');
html = html.replace(/“/g, '&ldquo;');
html = html.replace(/”/g, '&rdquo;');
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 = /^(?<recipientProperty>\w+?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?$/;
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 wont 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
};
}
}

View file

@ -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 {

View file

@ -0,0 +1,37 @@
<td dir="ltr" valign="top" align="center" style="vertical-align: top; color: {{textColor}}; font-family: inherit; font-size: 14px; text-align: center; padding: 0 8px;" nowrap>
<table class="feedback-buttons" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="background-color: {{backgroundColor}}; overflow: hidden; border-radius: 22px;border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tr>
<td width="16" height="{{sizes.height}}"></td>
<td class="{{className}}" background="{{iconUrl}}" bgcolor="{{textColor}}" width="{{sizes.iconWidth}}" height="{{sizes.height}}" valign="top" style="background-image: url({{iconUrl}});vertical-align: middle; text-align: center;background-size: cover; background-position: 0 50%; background-repeat:no-repeat;">
<!--[if gte mso 9]>
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width:{{sizesOutlook.iconWidth}}pt;height:{{sizesOutlook.height}}pt;">
<v:fill origin="0.5, 0.5" position="0.5, 0.5" type="tile" src={{iconUrl}} color="{{textColor}}" size="1,1" aspect="atleast" />
<v:textbox inset="0,0,0,0">
<![endif]-->
<div>
<a style="background-color: {{backgroundColor}};border: none; width: {{sizes.iconWidth}}px; height: {{sizes.height}}px; display: block" href="{{href}}" target="_blank"></a>
</div>
<!--[if gte mso 9]>
</v:textbox>
</v:rect>
<![endif]-->
</td>
<td style="text-align: right;font-size: 18px; vertical-align: middle; color: {{textColor}}!important; background-position: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';">
<div style="color: {{textColor}}"><!--[if mso]>
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{href}}" style="height:{{sizesOutlook.height}}pt;v-text-anchor:middle;width:{{sizesOutlook.width}}pt;" stroke="f">
<w:anchorlock/>
<center>
<![endif]-->
<a
href="{{href}}"
target="_blank"
style="padding: 0 8px 0 8px;border-radius: 0 22px 22px 0;color:{{textColor}}!important;display:inline-block;font-family: inherit;font-size:14px;font-weight:bold;line-height:38px;text-align:left;text-decoration:none;width:100px;-webkit-text-size-adjust:none;">
{{buttonText}}</a>
<!--[if mso]>
</center>
</v:rect>
<![endif]--></div>
</td>
</tr>
</table>
</td>

View file

@ -0,0 +1,27 @@
<div class="align-center" style="text-align: center;">
<hr
style="position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;">
<h2
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;">
Subscribe to <span style="white-space: nowrap; font-size: 26px !important;">continue reading.</span></h2>
<p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 440px;">Become a paid member of {{site.title}} to get access to all
<span style="white-space: nowrap;">subscriber-only content.</span></p>
<div class="btn btn-accent" style="box-sizing: border-box; width: 100%; display: table;">
<table border="0" cellspacing="0" cellpadding="0" align="center"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td align="center"
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;"
valign="top" bgcolor="{{accentColor}}">
<a href="{{paywall.signupUrl}}"
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;"
target="_blank">Subscribe
</a>
</td>
</tr>
</tbody>
</table>
</div>
<p style="margin: 0 0 1.5em 0; line-height: 1.6em;"></p>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,177 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]-->
<title>{{post.title}}</title>
{{>styles}}
</head>
<body>
<span class="preheader">{{preheader}}</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" width="100%">
<!-- Outlook doesn't respect max-width so we need an extra centered table -->
<!--[if mso]>
<tr>
<td>
<center>
<table border="0" cellpadding="0" cellspacing="0" width="600">
<![endif]-->
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
{{#if headerImage}}
<tr>
<td class="header-image" width="100%" align="center">
<img
src="{{headerImage}}"
{{#if headerImageWidth}}
width="{{headerImageWidth}}"
{{/if}}
>
</td>
</tr>
{{/if}}
{{#if (or showHeaderIcon showHeaderTitle showHeaderName) }}
<tr>
<td class="{{#if showHeaderTitle}}site-info-bordered{{else}}site-info{{/if}}" width="100%" align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
{{#if (and showHeaderIcon site.iconUrl) }}
<tr>
<td class="site-icon"><a href="{{site.url}}"><img src="{{site.iconUrl}}" alt="{{site.title}}" border="0"></a></td>
</tr>
{{/if}}
{{#if showHeaderTitle }}
<tr>
<td class="site-url {{#unless showHeaderName}}site-url-bottom-padding{{/unless}}"><div style="width: 100% !important;"><a href="{{site.url}}" class="site-title">{{site.title}}</a></div></td>
</tr>
{{/if}}
{{#if (and showHeaderName showHeaderTitle) }}
<tr>
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="{{site.url}}" class="site-subtitle">{{newsletter.name}}</a></div></td>
</tr>
{{/if}}
{{#if (and showHeaderName (not showHeaderTitle)) }}
<tr>
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="{{site.url}}" class="site-title">{{newsletter.name}}</a></div></td>
</tr>
{{/if}}
</table>
</td>
</tr>
{{/if}}
<tr>
<td class="{{classes.title}}">
<a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
</td>
</tr>
<tr>
<td>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="{{classes.meta}}">
By {{post.authors}} {{post.publishedAt}} <a href="{{post.url}}" class="view-online-link">View online →</a>
</td>
</tr>
</table>
</td>
</tr>
{{#if showFeatureImage }}
<tr>
<td class="feature-image
{{#if post.feature_image_caption }}
feature-image-with-caption
{{/if}}
"><img
src="{{post.feature_image}}"
{{#if post.feature_image_width }}
width="{{post.feature_image_width}}"
{{/if}}
{{#if post.feature_image_alt }}
alt="{{post.feature_image_alt}}"
{{/if}}
></td>
</tr>
{{#if post.feature_image_caption }}
<tr>
<td class="feature-image-caption" align="center">{{post.feature_image_caption}}</td>
</tr>
{{/if}}
{{/if}}
<tr>
<td class="{{classes.body}}">
<!-- POST CONTENT START -->
{{{html}}}
<!-- POST CONTENT END -->
{{#if paywall}}
{{>paywall}}
{{/if}}
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
{{#if feedbackButtons }}
<tr>
<td dir="ltr" width="100%" style="background-color: #ffffff; text-align: center; padding: 40px 4px; border-bottom: 1px solid #e5eff5" align="center">
<h3 style="text-align: center; margin-bottom: 22px; font-size: 17px; letter-spacing: -0.2px; margin-top: 0 !important;">Give feedback on this post</h3>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: auto; width: auto !important;">
<tr>
{{> feedbackButton feedbackButtons href=feedbackButtons.likeHref buttonText='More like this' className="like-icon" iconUrl="https://static.ghost.org/v5.0.0/images/thumbs-up.png" }}
{{> feedbackButton feedbackButtons href=feedbackButtons.dislikeHref buttonText='Less like this' className="dislike-icon" iconUrl="https://static.ghost.org/v5.0.0/images/thumbs-down.png" }}
</tr>
</table>
</td>
</tr>
{{/if}}
<tr>
<td class="wrapper" align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-top: 40px; padding-bottom: 30px;">
{{#if footerContent }}
<tr><td class="footer">{{{footerContent}}}</td></tr>
{{/if}}
<tr>
<td class="footer">{{site.title}} &copy; {{year}} <a href="%%{unsubscribe_url}%%">Unsubscribe</a></td>
</tr>
{{#if showBadge }}
<tr>
<td class="footer-powered"><a href="https://ghost.org/"><img src="https://static.ghost.org/v4.0.0/images/powered.png" border="0" width="142" height="30" class="gh-powered" alt="Powered by Ghost"></a></td>
</tr>
{{/if}}
</table>
</td>
</tr>
</table>
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
<!--[if mso]>
</table>
</center>
</td>
</tr>
<![endif]-->
</table>
</body>
</html>

View file

@ -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)
};

View file

@ -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"
}
}

View file

@ -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: ''}), '');
});
});
});

View file

@ -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');
});
});