2022-11-23 13:33:44 +03:00
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
const logging = require('@tryghost/logging');
|
|
|
|
|
const fs = require('fs').promises;
|
|
|
|
|
const path = require('path');
|
2023-03-20 16:30:42 +03:00
|
|
|
|
const {isUnsplashImage} = require('@tryghost/kg-default-cards/lib/utils');
|
|
|
|
|
const {textColorForBackgroundColor, darkenToContrastThreshold} = require('@tryghost/color-utils');
|
2022-11-29 13:27:17 +03:00
|
|
|
|
const {DateTime} = require('luxon');
|
|
|
|
|
const htmlToPlaintext = require('@tryghost/html-to-plaintext');
|
2023-03-22 13:52:41 +03:00
|
|
|
|
const tpl = require('@tryghost/tpl');
|
2023-06-29 11:40:04 +03:00
|
|
|
|
const cheerio = require('cheerio');
|
2023-03-22 13:52:41 +03:00
|
|
|
|
|
|
|
|
|
const messages = {
|
|
|
|
|
subscriptionStatus: {
|
2023-03-24 10:51:20 +03:00
|
|
|
|
free: '',
|
2023-03-22 13:52:41 +03:00
|
|
|
|
expired: 'Your subscription has expired.',
|
|
|
|
|
canceled: 'Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.',
|
|
|
|
|
active: 'Your subscription will renew on {date}.',
|
|
|
|
|
trial: 'Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.',
|
|
|
|
|
complimentaryExpires: 'Your subscription will expire on {date}.',
|
|
|
|
|
complimentaryInfinite: ''
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2023-03-24 14:14:00 +03:00
|
|
|
|
function escapeHtml(unsafe) {
|
|
|
|
|
return unsafe
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 13:52:41 +03:00
|
|
|
|
function formatDateLong(date, timezone) {
|
|
|
|
|
return DateTime.fromJSDate(date).setZone(timezone).setLocale('en-gb').toLocaleString({
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'long',
|
|
|
|
|
day: 'numeric'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeRegExp(string) {
|
|
|
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
}
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
2022-11-23 13:33:44 +03:00
|
|
|
|
/**
|
|
|
|
|
* @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
|
2023-03-22 13:52:41 +03:00
|
|
|
|
* @prop {'free'|'paid'|'comped'} status
|
2023-03-15 19:08:57 +03:00
|
|
|
|
* @prop {Date|null} createdAt This can be null if the member has been deleted for older email recipient rows
|
2023-03-22 13:52:41 +03:00
|
|
|
|
* @prop {MemberLikeSubscription[]} subscriptions Required to get trial end / next renewal date / expire at date for paid member
|
|
|
|
|
* @prop {MemberLikeTier[]} tiers Required to get the expiry date in case of a comped member
|
|
|
|
|
*
|
|
|
|
|
* @typedef {object} MemberLikeSubscription
|
|
|
|
|
* @prop {string} status
|
|
|
|
|
* @prop {boolean} cancel_at_period_end
|
|
|
|
|
* @prop {Date|null} trial_end_at
|
|
|
|
|
* @prop {Date} current_period_end
|
|
|
|
|
*
|
|
|
|
|
* @typedef {object} MemberLikeTier
|
|
|
|
|
* @prop {string} product_id
|
|
|
|
|
* @prop {Date|null} expiry_at
|
2022-11-23 13:33:44 +03:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @typedef {object} ReplacementDefinition
|
2022-11-29 13:27:17 +03:00
|
|
|
|
* @prop {string} id
|
|
|
|
|
* @prop {RegExp} token
|
2022-11-23 13:33:44 +03:00
|
|
|
|
* @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 {
|
2022-11-29 13:27:17 +03:00
|
|
|
|
#settingsCache;
|
|
|
|
|
#settingsHelpers;
|
|
|
|
|
|
|
|
|
|
#renderers;
|
|
|
|
|
|
|
|
|
|
#imageSize;
|
|
|
|
|
#urlUtils;
|
|
|
|
|
#getPostUrl;
|
2022-12-09 13:17:22 +03:00
|
|
|
|
#storageUtils;
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
#handlebars;
|
|
|
|
|
#renderTemplate;
|
|
|
|
|
#linkReplacer;
|
|
|
|
|
#linkTracking;
|
|
|
|
|
#memberAttributionService;
|
2023-02-16 13:26:35 +03:00
|
|
|
|
#outboundLinkTagger;
|
2022-11-29 13:27:17 +03:00
|
|
|
|
#audienceFeedbackService;
|
2023-03-14 19:11:24 +03:00
|
|
|
|
#labs;
|
2023-03-20 16:30:42 +03:00
|
|
|
|
#models;
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @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
|
2023-03-22 14:32:45 +03:00
|
|
|
|
* @param {{getImageSizeFromUrl(url: string): Promise<{width: number, height: number}>}} dependencies.imageSize
|
2022-11-29 13:27:17 +03:00
|
|
|
|
* @param {{urlFor(type: string, optionsOrAbsolute, absolute): string, isSiteUrl(url, context): boolean}} dependencies.urlUtils
|
2022-12-09 13:17:22 +03:00
|
|
|
|
* @param {{isLocalImage(url: string): boolean}} dependencies.storageUtils
|
2022-11-29 13:27:17 +03:00
|
|
|
|
* @param {(post: Post) => string} dependencies.getPostUrl
|
|
|
|
|
* @param {object} dependencies.linkReplacer
|
|
|
|
|
* @param {object} dependencies.linkTracking
|
|
|
|
|
* @param {object} dependencies.memberAttributionService
|
|
|
|
|
* @param {object} dependencies.audienceFeedbackService
|
2023-02-16 13:26:35 +03:00
|
|
|
|
* @param {object} dependencies.outboundLinkTagger
|
2023-03-14 19:11:24 +03:00
|
|
|
|
* @param {object} dependencies.labs
|
2023-03-20 16:30:42 +03:00
|
|
|
|
* @param {{Post: object}} dependencies.models
|
2022-11-29 13:27:17 +03:00
|
|
|
|
*/
|
|
|
|
|
constructor({
|
|
|
|
|
settingsCache,
|
|
|
|
|
settingsHelpers,
|
|
|
|
|
renderers,
|
|
|
|
|
imageSize,
|
|
|
|
|
urlUtils,
|
2022-12-09 13:17:22 +03:00
|
|
|
|
storageUtils,
|
2022-11-29 13:27:17 +03:00
|
|
|
|
getPostUrl,
|
|
|
|
|
linkReplacer,
|
|
|
|
|
linkTracking,
|
|
|
|
|
memberAttributionService,
|
2023-02-16 13:26:35 +03:00
|
|
|
|
audienceFeedbackService,
|
2023-03-14 19:11:24 +03:00
|
|
|
|
outboundLinkTagger,
|
2023-03-20 16:30:42 +03:00
|
|
|
|
labs,
|
|
|
|
|
models
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}) {
|
|
|
|
|
this.#settingsCache = settingsCache;
|
|
|
|
|
this.#settingsHelpers = settingsHelpers;
|
|
|
|
|
this.#renderers = renderers;
|
|
|
|
|
this.#imageSize = imageSize;
|
|
|
|
|
this.#urlUtils = urlUtils;
|
2022-12-09 13:17:22 +03:00
|
|
|
|
this.#storageUtils = storageUtils;
|
2022-11-29 13:27:17 +03:00
|
|
|
|
this.#getPostUrl = getPostUrl;
|
|
|
|
|
this.#linkReplacer = linkReplacer;
|
|
|
|
|
this.#linkTracking = linkTracking;
|
|
|
|
|
this.#memberAttributionService = memberAttributionService;
|
|
|
|
|
this.#audienceFeedbackService = audienceFeedbackService;
|
2023-02-16 13:26:35 +03:00
|
|
|
|
this.#outboundLinkTagger = outboundLinkTagger;
|
2023-03-14 19:11:24 +03:00
|
|
|
|
this.#labs = labs;
|
2023-03-20 16:30:42 +03:00
|
|
|
|
this.#models = models;
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-23 13:33:44 +03:00
|
|
|
|
/**
|
2023-01-25 16:56:37 +03:00
|
|
|
|
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.
|
2022-11-23 13:33:44 +03:00
|
|
|
|
@param {Post} post
|
2023-07-21 01:48:48 +03:00
|
|
|
|
@returns {Promise<Segment[]>}
|
2022-11-23 13:33:44 +03:00
|
|
|
|
*/
|
2023-07-21 01:48:48 +03:00
|
|
|
|
async getSegments(post) {
|
2022-11-29 13:27:17 +03:00
|
|
|
|
const allowedSegments = ['status:free', 'status:-free'];
|
2023-07-21 01:48:48 +03:00
|
|
|
|
const html = await this.renderPostBaseHtml(post);
|
2022-11-30 13:51:58 +03:00
|
|
|
|
|
2023-01-25 16:56:37 +03:00
|
|
|
|
/**
|
|
|
|
|
* Always add free and paid segments if email has paywall card
|
|
|
|
|
*/
|
|
|
|
|
if (html.indexOf('<!--members-only-->') !== -1) {
|
|
|
|
|
// We have different content between free and paid members
|
|
|
|
|
return allowedSegments;
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
const $ = cheerio.load(html);
|
2022-11-30 13:51:58 +03:00
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
let allSegments = $('[data-gh-segment]')
|
|
|
|
|
.get()
|
|
|
|
|
.map(el => el.attribs['data-gh-segment']);
|
2022-11-30 13:51:58 +03:00
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
const segments = [...new Set(allSegments)].filter(segment => allowedSegments.includes(segment));
|
|
|
|
|
if (segments.length === 0) {
|
2023-01-25 16:56:37 +03:00
|
|
|
|
// No difference in email content between free and paid
|
2022-11-29 13:27:17 +03:00
|
|
|
|
return [null];
|
|
|
|
|
}
|
2023-01-25 16:56:37 +03:00
|
|
|
|
|
|
|
|
|
// We have different content between free and paid members
|
|
|
|
|
return allowedSegments;
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}
|
|
|
|
|
|
2023-07-21 01:48:48 +03:00
|
|
|
|
async renderPostBaseHtml(post) {
|
2023-02-02 20:12:46 +03:00
|
|
|
|
const postUrl = this.#getPostUrl(post);
|
2022-11-29 13:27:17 +03:00
|
|
|
|
let html;
|
|
|
|
|
if (post.get('lexical')) {
|
2023-07-21 01:48:48 +03:00
|
|
|
|
// only lexical's renderer is async
|
|
|
|
|
html = await this.#renderers.lexical.render(
|
2023-02-02 20:12:46 +03:00
|
|
|
|
post.get('lexical'), {target: 'email', postUrl}
|
2022-11-29 13:27:17 +03:00
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
html = this.#renderers.mobiledoc.render(
|
2023-02-02 20:12:46 +03:00
|
|
|
|
JSON.parse(post.get('mobiledoc')), {target: 'email', postUrl}
|
2022-11-29 13:27:17 +03:00
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return html;
|
2022-11-23 13:33:44 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2022-11-30 13:51:58 +03:00
|
|
|
|
*
|
|
|
|
|
* @param {Post} post
|
|
|
|
|
* @param {Newsletter} newsletter
|
|
|
|
|
* @param {Segment} segment
|
|
|
|
|
* @param {EmailRenderOptions} options
|
2022-11-23 13:33:44 +03:00
|
|
|
|
* @returns {Promise<EmailBody>}
|
|
|
|
|
*/
|
|
|
|
|
async renderBody(post, newsletter, segment, options) {
|
2023-07-21 01:48:48 +03:00
|
|
|
|
let html = await this.renderPostBaseHtml(post);
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
2023-01-11 17:46:59 +03:00
|
|
|
|
// 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}');
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
// 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;
|
2022-11-30 13:51:58 +03:00
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
// Remove the members-only content
|
|
|
|
|
html = html.slice(0, membersOnlyIndex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-29 11:40:04 +03:00
|
|
|
|
let $ = cheerio.load(html);
|
|
|
|
|
|
|
|
|
|
// Remove parts of the HTML not applicable to the current segment - We do this
|
|
|
|
|
// before rendering the template as the preheader for the email may be generated
|
|
|
|
|
// using the HTML and we don't want to include content that should not be
|
|
|
|
|
// visible depending on the segment
|
|
|
|
|
$('[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');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
html = $.html();
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
const templateData = await this.getTemplateData({
|
2022-11-30 13:51:58 +03:00
|
|
|
|
post,
|
|
|
|
|
newsletter,
|
2022-11-29 13:27:17 +03:00
|
|
|
|
html,
|
2023-06-29 11:40:04 +03:00
|
|
|
|
addPaywall,
|
|
|
|
|
segment
|
2022-11-29 13:27:17 +03:00
|
|
|
|
});
|
|
|
|
|
html = await this.renderTemplate(templateData);
|
|
|
|
|
|
2023-08-08 14:22:56 +03:00
|
|
|
|
// We pass the base option to the link replacer so relative links are replaced with absolute links, relative to this base url
|
|
|
|
|
const base = templateData.post.url;
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
// Link tracking
|
|
|
|
|
if (options.clickTrackingEnabled) {
|
2023-08-08 14:22:56 +03:00
|
|
|
|
html = await this.#linkReplacer.replace(html, async (url, originalPath) => {
|
|
|
|
|
if (originalPath.startsWith('%%{') && originalPath.endsWith('}%%')) {
|
|
|
|
|
// Don't add the base url to replacement strings
|
|
|
|
|
return originalPath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ignore empty hashtags (used as a hack for email addresses to prevent making them clickable)
|
|
|
|
|
if (originalPath === '#') {
|
|
|
|
|
return originalPath;
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
// 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
|
2023-02-16 13:26:35 +03:00
|
|
|
|
url = this.#outboundLinkTagger.addToUrl(url, newsletter);
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
// 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
|
2023-02-16 13:26:35 +03:00
|
|
|
|
url = this.#outboundLinkTagger.addToUrl(url);
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
2023-08-08 14:22:56 +03:00
|
|
|
|
}, {base});
|
|
|
|
|
} else {
|
|
|
|
|
// Replace all relative links to absolute ones
|
|
|
|
|
html = await this.#linkReplacer.replace(html, (url, originalPath) => {
|
|
|
|
|
if (originalPath.startsWith('%%{') && originalPath.endsWith('}%%')) {
|
|
|
|
|
// Don't add the base url to replacement strings
|
|
|
|
|
return originalPath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ignore empty hashtags (used as a hack for email addresses to prevent making them clickable)
|
|
|
|
|
if (originalPath === '#') {
|
|
|
|
|
return originalPath;
|
|
|
|
|
}
|
|
|
|
|
return url;
|
|
|
|
|
}, {base});
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Juice HTML (inline CSS)
|
|
|
|
|
const juice = require('juice');
|
2023-07-25 04:33:56 +03:00
|
|
|
|
juice.heightElements = ['TABLE', 'TD', 'TH'];
|
|
|
|
|
juice.widthElements = ['TABLE', 'TD', 'TH'];
|
2023-03-22 18:40:35 +03:00
|
|
|
|
html = juice(html, {inlinePseudoElements: true, removeStyleTags: true});
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
// happens after inlining of CSS so we can change element types without worrying about styling
|
2023-06-29 11:40:04 +03:00
|
|
|
|
$ = cheerio.load(html);
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
// force all links to open in new tab
|
|
|
|
|
$('a').attr('target', '_blank');
|
2022-11-30 13:51:58 +03:00
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
// convert figure and figcaption to div so that Outlook applies margins
|
|
|
|
|
$('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
|
2022-11-30 13:51:58 +03:00
|
|
|
|
|
2023-04-05 13:51:18 +03:00
|
|
|
|
// Remove duplicate black/white images (CSS based solution not working in Outlook)
|
|
|
|
|
if (templateData.backgroundIsDark) {
|
|
|
|
|
$('img.is-light-background').each((i, elem) => {
|
|
|
|
|
$(elem).remove();
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
$('img.is-dark-background').each((i, elem) => {
|
|
|
|
|
$(elem).remove();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
// Convert DOM back to HTML
|
2022-11-30 13:51:58 +03:00
|
|
|
|
html = $.html(); // () Fix for vscode syntax highlighter
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
// Replacement strings
|
2023-03-07 17:34:43 +03:00
|
|
|
|
const replacementDefinitions = this.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
// 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, '”');
|
|
|
|
|
|
2022-11-23 13:33:44 +03:00
|
|
|
|
return {
|
2022-11-29 13:27:17 +03:00
|
|
|
|
html,
|
|
|
|
|
plaintext,
|
|
|
|
|
replacements: replacementDefinitions
|
2022-11-23 13:33:44 +03:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
/**
|
|
|
|
|
* 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`
|
|
|
|
|
*
|
2023-03-22 13:52:41 +03:00
|
|
|
|
* @param {string} [uuid] member uuid
|
2022-11-29 13:27:17 +03:00
|
|
|
|
* @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;
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 13:52:41 +03:00
|
|
|
|
/**
|
|
|
|
|
* createManageAccountUrl
|
|
|
|
|
*
|
|
|
|
|
* @param {string} [uuid] member uuid
|
|
|
|
|
*/
|
|
|
|
|
createManageAccountUrl(uuid) {
|
|
|
|
|
const siteUrl = this.#urlUtils.urlFor('home', true);
|
|
|
|
|
const url = new URL(siteUrl);
|
|
|
|
|
url.hash = '#/portal/account';
|
|
|
|
|
|
|
|
|
|
return url.href;
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-24 10:51:20 +03:00
|
|
|
|
/**
|
|
|
|
|
* Returns whether a paid member is trialing a subscription
|
|
|
|
|
*/
|
|
|
|
|
isMemberTrialing(member) {
|
|
|
|
|
// Do we have an active subscription?
|
|
|
|
|
if (member.status === 'paid') {
|
|
|
|
|
let activeSubscription = member.subscriptions.find((subscription) => {
|
|
|
|
|
return subscription.status === 'trialing';
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!activeSubscription) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Translate to a human readable string
|
|
|
|
|
if (activeSubscription.trial_end_at && activeSubscription.trial_end_at > new Date() && activeSubscription.status === 'trialing') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 13:52:41 +03:00
|
|
|
|
/**
|
|
|
|
|
* @param {MemberLike} member
|
|
|
|
|
* @returns {string}
|
|
|
|
|
*/
|
|
|
|
|
getMemberStatusText(member) {
|
|
|
|
|
if (member.status === 'free') {
|
|
|
|
|
// Not really used, but as a backup
|
|
|
|
|
return tpl(messages.subscriptionStatus.free);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Do we have an active subscription?
|
|
|
|
|
if (member.status === 'paid') {
|
|
|
|
|
let activeSubscription = member.subscriptions.find((subscription) => {
|
|
|
|
|
return subscription.status === 'active';
|
|
|
|
|
}) ?? member.subscriptions.find((subscription) => {
|
|
|
|
|
return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.status);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!activeSubscription && !member.tiers.length) {
|
|
|
|
|
// No subscription?
|
|
|
|
|
return tpl(messages.subscriptionStatus.expired);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!activeSubscription) {
|
|
|
|
|
if (!member.tiers[0]?.expiry_at) {
|
|
|
|
|
return tpl(messages.subscriptionStatus.complimentaryInfinite);
|
|
|
|
|
}
|
|
|
|
|
// Create one manually that is expiring
|
|
|
|
|
activeSubscription = {
|
|
|
|
|
cancel_at_period_end: true,
|
|
|
|
|
current_period_end: member.tiers[0].expiry_at,
|
|
|
|
|
status: 'active',
|
|
|
|
|
trial_end_at: null
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const timezone = this.#settingsCache.get('timezone');
|
|
|
|
|
|
|
|
|
|
// Translate to a human readable string
|
|
|
|
|
if (activeSubscription.trial_end_at && activeSubscription.trial_end_at > new Date() && activeSubscription.status === 'trialing') {
|
|
|
|
|
const date = formatDateLong(activeSubscription.trial_end_at, timezone);
|
|
|
|
|
return tpl(messages.subscriptionStatus.trial, {date});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const date = formatDateLong(activeSubscription.current_period_end, timezone);
|
|
|
|
|
if (activeSubscription.cancel_at_period_end) {
|
|
|
|
|
return tpl(messages.subscriptionStatus.canceled, {date});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tpl(messages.subscriptionStatus.active, {date});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const expires = member.tiers[0]?.expiry_at ?? null;
|
|
|
|
|
|
|
|
|
|
if (expires) {
|
|
|
|
|
const timezone = this.#settingsCache.get('timezone');
|
|
|
|
|
const date = formatDateLong(expires, timezone);
|
|
|
|
|
return tpl(messages.subscriptionStatus.complimentaryExpires, {date});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tpl(messages.subscriptionStatus.complimentaryInfinite);
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
/**
|
|
|
|
|
* Note that we only look in HTML because plaintext and HTML are essentially the same content
|
|
|
|
|
* @returns {ReplacementDefinition[]}
|
|
|
|
|
*/
|
2023-03-07 17:34:43 +03:00
|
|
|
|
buildReplacementDefinitions({html, newsletterUuid}) {
|
2022-11-29 13:27:17 +03:00
|
|
|
|
const baseDefinitions = [
|
|
|
|
|
{
|
|
|
|
|
id: 'unsubscribe_url',
|
|
|
|
|
getValue: (member) => {
|
2023-03-07 17:34:43 +03:00
|
|
|
|
return this.createUnsubscribeUrl(member.uuid, {newsletterUuid});
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}
|
|
|
|
|
},
|
2023-03-22 13:52:41 +03:00
|
|
|
|
{
|
|
|
|
|
id: 'manage_account_url',
|
|
|
|
|
getValue: (member) => {
|
|
|
|
|
return this.createManageAccountUrl(member.uuid);
|
|
|
|
|
}
|
|
|
|
|
},
|
2022-11-29 13:27:17 +03:00
|
|
|
|
{
|
|
|
|
|
id: 'uuid',
|
|
|
|
|
getValue: (member) => {
|
|
|
|
|
return member.uuid;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'first_name',
|
|
|
|
|
getValue: (member) => {
|
2022-11-30 13:51:58 +03:00
|
|
|
|
return member.name?.split(' ')[0];
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}
|
2023-03-15 19:08:57 +03:00
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'name',
|
|
|
|
|
getValue: (member) => {
|
|
|
|
|
return member.name;
|
|
|
|
|
}
|
|
|
|
|
},
|
2023-03-22 17:15:34 +03:00
|
|
|
|
{
|
|
|
|
|
id: 'name_class',
|
|
|
|
|
getValue: (member) => {
|
|
|
|
|
return member.name ? '' : 'hidden';
|
|
|
|
|
}
|
|
|
|
|
},
|
2023-03-15 19:08:57 +03:00
|
|
|
|
{
|
|
|
|
|
id: 'email',
|
|
|
|
|
getValue: (member) => {
|
|
|
|
|
return member.email;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'created_at',
|
|
|
|
|
getValue: (member) => {
|
|
|
|
|
const timezone = this.#settingsCache.get('timezone');
|
2023-03-22 13:52:41 +03:00
|
|
|
|
return member.createdAt ? formatDateLong(member.createdAt, timezone) : '';
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'status',
|
|
|
|
|
getValue: (member) => {
|
|
|
|
|
if (member.status === 'comped') {
|
|
|
|
|
return 'complimentary';
|
|
|
|
|
}
|
2023-03-24 10:51:20 +03:00
|
|
|
|
if (this.isMemberTrialing(member)) {
|
|
|
|
|
return 'trialing';
|
|
|
|
|
}
|
2023-03-22 13:52:41 +03:00
|
|
|
|
return member.status;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'status_text',
|
|
|
|
|
getValue: (member) => {
|
|
|
|
|
return this.getMemberStatusText(member);
|
2023-03-15 19:08:57 +03:00
|
|
|
|
}
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// 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+?)(?:,? *(?:"|")(?<fallback>.*?)(?:"|"))?$/;
|
|
|
|
|
|
|
|
|
|
// 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,
|
2022-12-02 12:49:01 +03:00
|
|
|
|
originalId: recipientProperty,
|
2023-03-07 17:34:43 +03:00
|
|
|
|
token: new RegExp(escapeRegExp(replacementMatch).replace(/(?:"|")/g, '(?:"|")'), 'g'),
|
2022-11-29 13:27:17 +03:00
|
|
|
|
getValue: fallback ? (member => definition.getValue(member) || fallback) : definition.getValue
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-02 12:49:01 +03:00
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
return replacements;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async renderTemplate(data) {
|
2023-09-26 17:25:03 +03:00
|
|
|
|
this.#handlebars = require('handlebars').create();
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
// 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
|
2023-06-01 14:32:37 +03:00
|
|
|
|
if (this.#labs.isSet('emailCustomization')) {
|
2023-04-05 16:28:20 +03:00
|
|
|
|
const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles.hbs`), 'utf8');
|
|
|
|
|
this.#handlebars.registerPartial('styles', cssPartialSource);
|
|
|
|
|
} else {
|
|
|
|
|
const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles-old.hbs`), 'utf8');
|
|
|
|
|
this.#handlebars.registerPartial('styles', cssPartialSource);
|
|
|
|
|
}
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2023-03-17 17:57:32 +03:00
|
|
|
|
const feedbackButtonMobilePartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `feedback-button-mobile.hbs`), 'utf8');
|
|
|
|
|
this.#handlebars.registerPartial('feedbackButtonMobile', feedbackButtonMobilePartial);
|
|
|
|
|
|
2023-03-20 16:30:42 +03:00
|
|
|
|
const latestPostsPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `latest-posts.hbs`), 'utf8');
|
|
|
|
|
this.#handlebars.registerPartial('latestPosts', latestPostsPartial);
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
// Actual template
|
2023-06-01 14:32:37 +03:00
|
|
|
|
if (this.#labs.isSet('emailCustomization')) {
|
2023-04-05 16:28:20 +03:00
|
|
|
|
const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template.hbs`), 'utf8');
|
|
|
|
|
this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString());
|
|
|
|
|
} else {
|
|
|
|
|
const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template-old.hbs`), 'utf8');
|
|
|
|
|
this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString());
|
|
|
|
|
}
|
2022-11-29 13:27:17 +03:00
|
|
|
|
return this.#renderTemplate(data);
|
2022-11-23 13:33:44 +03:00
|
|
|
|
}
|
|
|
|
|
|
2022-12-14 13:24:26 +03:00
|
|
|
|
/**
|
|
|
|
|
* Get email preheader text from post model
|
|
|
|
|
* @param {object} postModel
|
|
|
|
|
* @returns
|
|
|
|
|
*/
|
2023-06-29 11:40:04 +03:00
|
|
|
|
#getEmailPreheader(postModel, segment, html) {
|
2022-12-14 13:24:26 +03:00
|
|
|
|
let plaintext = postModel.get('plaintext');
|
|
|
|
|
let customExcerpt = postModel.get('custom_excerpt');
|
|
|
|
|
if (customExcerpt) {
|
|
|
|
|
return customExcerpt;
|
|
|
|
|
} else {
|
|
|
|
|
if (plaintext) {
|
2023-06-29 11:40:04 +03:00
|
|
|
|
// The plaintext field on the model may contain paid only content
|
|
|
|
|
// so we use the provided HTML to generate the plaintext as this
|
|
|
|
|
// should have already had the paid content removed
|
|
|
|
|
if (segment === 'status:free') {
|
|
|
|
|
plaintext = htmlToPlaintext.email(html);
|
|
|
|
|
}
|
2022-12-14 13:24:26 +03:00
|
|
|
|
return plaintext.substring(0, 500);
|
|
|
|
|
} else {
|
|
|
|
|
return `${postModel.get('title')} – `;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 18:09:59 +03:00
|
|
|
|
truncateText(text, maxLength) {
|
|
|
|
|
if (text && text.length > maxLength) {
|
|
|
|
|
return text.substring(0, maxLength - 1).trim() + '…';
|
|
|
|
|
} else {
|
|
|
|
|
return text ?? '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-24 14:14:00 +03:00
|
|
|
|
/**
|
|
|
|
|
*
|
|
|
|
|
* @param {*} text
|
|
|
|
|
* @param {number} maxLength
|
|
|
|
|
* @param {number} maxLengthMobile should be larger than maxLength
|
|
|
|
|
* @returns
|
|
|
|
|
*/
|
|
|
|
|
truncateHtml(text, maxLength, maxLengthMobile) {
|
|
|
|
|
if (!maxLengthMobile || maxLength >= maxLengthMobile) {
|
|
|
|
|
return escapeHtml(this.truncateText(text, maxLength));
|
|
|
|
|
}
|
|
|
|
|
if (text && text.length > maxLength) {
|
|
|
|
|
if (text.length <= maxLengthMobile) {
|
|
|
|
|
return escapeHtml(text.substring(0, maxLength - 1)) + '<span class="mobile-only">' + escapeHtml(text.substring(maxLength - 1, maxLengthMobile - 1)) + '</span>' + '<span class="hide-mobile">…</span>';
|
|
|
|
|
}
|
|
|
|
|
return escapeHtml(text.substring(0, maxLength - 1)) + '<span class="mobile-only">' + escapeHtml(text.substring(maxLength - 1, maxLengthMobile - 1)) + '</span>' + '…';
|
|
|
|
|
} else {
|
|
|
|
|
return escapeHtml(text ?? '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-29 07:03:49 +03:00
|
|
|
|
#getBackgroundColor(newsletter) {
|
|
|
|
|
/** @type {'light' | 'dark' | string | null} */
|
|
|
|
|
const value = newsletter.get('background_color');
|
|
|
|
|
|
|
|
|
|
const validHex = /#([0-9a-f]{3}){1,2}$/i;
|
|
|
|
|
|
|
|
|
|
if (validHex.test(value)) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value === 'dark') {
|
|
|
|
|
return '#15212a';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// value === dark, value === null, value is not valid hex
|
|
|
|
|
return '#ffffff';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#getBorderColor(newsletter, accentColor) {
|
|
|
|
|
/** @type {'transparent' | 'accent' | 'dark' | string | null} */
|
|
|
|
|
const value = newsletter.get('border_color');
|
|
|
|
|
|
|
|
|
|
const validHex = /#([0-9a-f]{3}){1,2}$/i;
|
|
|
|
|
|
|
|
|
|
if (validHex.test(value)) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-03 11:46:47 +03:00
|
|
|
|
if (value === 'auto') {
|
|
|
|
|
const backgroundColor = this.#getBackgroundColor(newsletter);
|
|
|
|
|
return textColorForBackgroundColor(backgroundColor).hex();
|
2023-03-29 07:03:49 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value === 'accent') {
|
|
|
|
|
return accentColor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// value === 'transparent', value === null, value is not valid hex
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#getTitleColor(newsletter, accentColor) {
|
|
|
|
|
/** @type {'accent' | 'auto' | string | null} */
|
|
|
|
|
const value = newsletter.get('title_color');
|
|
|
|
|
|
|
|
|
|
const validHex = /#([0-9a-f]{3}){1,2}$/i;
|
|
|
|
|
|
|
|
|
|
if (validHex.test(value)) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value === 'accent') {
|
|
|
|
|
return accentColor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// value === 'auto', value === null, value is not valid hex
|
|
|
|
|
const backgroundColor = this.#getBackgroundColor(newsletter);
|
|
|
|
|
return textColorForBackgroundColor(backgroundColor).hex();
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
/**
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
2023-06-29 11:40:04 +03:00
|
|
|
|
async getTemplateData({post, newsletter, html, addPaywall, segment}) {
|
2023-01-11 14:13:13 +03:00
|
|
|
|
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';
|
|
|
|
|
}
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
2023-03-29 07:03:49 +03:00
|
|
|
|
const backgroundColor = this.#getBackgroundColor(newsletter);
|
|
|
|
|
const backgroundIsDark = textColorForBackgroundColor(backgroundColor).hex().toLowerCase() === '#ffffff';
|
|
|
|
|
const borderColor = this.#getBorderColor(newsletter, accentColor);
|
|
|
|
|
const secondaryBorderColor = textColorForBackgroundColor(backgroundColor).alpha(0.12).toString();
|
|
|
|
|
const titleColor = this.#getTitleColor(newsletter, accentColor);
|
|
|
|
|
const textColor = textColorForBackgroundColor(backgroundColor).hex();
|
2023-03-30 16:33:46 +03:00
|
|
|
|
const secondaryTextColor = textColorForBackgroundColor(backgroundColor).alpha(0.5).toString();
|
2023-03-29 07:03:49 +03:00
|
|
|
|
const linkColor = backgroundIsDark ? '#ffffff' : accentColor;
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
const {href: headerImage, width: headerImageWidth} = await this.limitImageWidth(newsletter.get('header_image'));
|
2023-03-22 14:32:45 +03:00
|
|
|
|
const {href: postFeatureImage, width: postFeatureImageWidth, height: postFeatureImageHeight} = await this.limitImageWidth(post.get('feature_image'));
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
const timezone = this.#settingsCache.get('timezone');
|
2023-01-11 14:26:09 +03:00
|
|
|
|
const publishedAt = (post.get('published_at') ? DateTime.fromJSDate(post.get('published_at')) : DateTime.local()).setZone(timezone).setLocale('en-gb').toLocaleString({
|
2022-11-29 13:27:17 +03:00
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let authors;
|
|
|
|
|
const postAuthors = await post.getLazyRelation('authors');
|
2023-01-11 14:13:13 +03:00
|
|
|
|
if (postAuthors?.models) {
|
2022-11-29 13:27:17 +03:00
|
|
|
|
if (postAuthors.models.length <= 2) {
|
|
|
|
|
authors = postAuthors.models.map(author => author.get('name')).join(' & ');
|
|
|
|
|
} else {
|
2023-01-11 14:13:13 +03:00
|
|
|
|
authors = `${postAuthors.models[0].get('name')} & ${postAuthors.models.length - 1} others`;
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}%%');
|
|
|
|
|
|
2023-03-14 19:11:24 +03:00
|
|
|
|
const commentUrl = new URL(postUrl);
|
2023-03-17 12:27:23 +03:00
|
|
|
|
commentUrl.hash = '#ghost-comments';
|
2023-03-14 19:11:24 +03:00
|
|
|
|
|
2023-03-17 11:01:35 +03:00
|
|
|
|
const hasEmailOnlyFlag = post.related('posts_meta')?.get('email_only') ?? false;
|
|
|
|
|
|
2023-03-20 16:30:42 +03:00
|
|
|
|
const latestPosts = [];
|
|
|
|
|
let latestPostsHasImages = false;
|
2023-03-24 12:30:41 +03:00
|
|
|
|
if (newsletter.get('show_latest_posts')) {
|
2023-03-20 16:30:42 +03:00
|
|
|
|
// Fetch last 3 published posts
|
|
|
|
|
const {data} = await this.#models.Post.findPage({
|
2023-03-24 11:18:51 +03:00
|
|
|
|
filter: 'status:published+id:-' + post.id,
|
2023-03-20 16:30:42 +03:00
|
|
|
|
order: 'published_at DESC',
|
|
|
|
|
limit: 3
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const latestPost of data) {
|
|
|
|
|
// Please also adjust email-latest-posts-image if you make changes to the image width (100 x 2 = 200 -> should be in email-latest-posts-image)
|
2023-03-24 12:17:43 +03:00
|
|
|
|
const {href: featureImage, width: featureImageWidth, height: featureImageHeight} = await this.limitImageWidth(latestPost.get('feature_image'), 100, 100);
|
2023-03-22 17:52:21 +03:00
|
|
|
|
const {href: featureImageMobile, width: featureImageMobileWidth, height: featureImageMobileHeight} = await this.limitImageWidth(latestPost.get('feature_image'), 600, 480);
|
2023-03-20 16:30:42 +03:00
|
|
|
|
|
|
|
|
|
latestPosts.push({
|
2023-03-24 14:14:00 +03:00
|
|
|
|
title: this.truncateHtml(latestPost.get('title'), featureImage ? 85 : 105, 105),
|
2023-03-20 16:30:42 +03:00
|
|
|
|
url: this.#getPostUrl(latestPost),
|
2023-03-22 17:52:21 +03:00
|
|
|
|
featureImage: featureImage ? {
|
|
|
|
|
src: featureImage,
|
|
|
|
|
width: featureImageWidth,
|
|
|
|
|
height: featureImageHeight
|
|
|
|
|
} : null,
|
|
|
|
|
featureImageMobile: featureImageMobile ? {
|
|
|
|
|
src: featureImageMobile,
|
|
|
|
|
width: featureImageMobileWidth,
|
|
|
|
|
height: featureImageMobileHeight
|
2023-03-22 18:09:59 +03:00
|
|
|
|
} : null,
|
2023-03-24 14:14:00 +03:00
|
|
|
|
excerpt: this.truncateHtml(latestPost.get('custom_excerpt') || latestPost.get('plaintext'), featureImage ? 60 : 70, 105)
|
2023-03-20 16:30:42 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (featureImage) {
|
|
|
|
|
latestPostsHasImages = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
const data = {
|
|
|
|
|
site: {
|
|
|
|
|
title: this.#settingsCache.get('title'),
|
|
|
|
|
url: this.#urlUtils.urlFor('home', true),
|
2022-11-30 13:51:58 +03:00
|
|
|
|
iconUrl: this.#settingsCache.get('icon') ?
|
2022-11-29 13:27:17 +03:00
|
|
|
|
this.#urlUtils.urlFor('image', {
|
|
|
|
|
image: this.#settingsCache.get('icon')
|
|
|
|
|
}, true) : null
|
|
|
|
|
},
|
2023-06-29 11:40:04 +03:00
|
|
|
|
preheader: this.#getEmailPreheader(post, segment, html),
|
2022-11-29 13:27:17 +03:00
|
|
|
|
html,
|
|
|
|
|
|
|
|
|
|
post: {
|
|
|
|
|
title: post.get('title'),
|
|
|
|
|
url: postUrl,
|
2023-03-14 19:11:24 +03:00
|
|
|
|
commentUrl: commentUrl.href,
|
2022-11-29 13:27:17 +03:00
|
|
|
|
authors,
|
|
|
|
|
publishedAt,
|
|
|
|
|
feature_image: postFeatureImage,
|
|
|
|
|
feature_image_width: postFeatureImageWidth,
|
2023-03-22 14:32:45 +03:00
|
|
|
|
feature_image_height: postFeatureImageHeight,
|
2022-11-29 13:27:17 +03:00
|
|
|
|
feature_image_alt: post.related('posts_meta')?.get('feature_image_alt'),
|
|
|
|
|
feature_image_caption: post.related('posts_meta')?.get('feature_image_caption')
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
newsletter: {
|
2023-03-14 13:29:43 +03:00
|
|
|
|
name: newsletter.get('name'),
|
2023-03-14 19:11:24 +03:00
|
|
|
|
showPostTitleSection: newsletter.get('show_post_title_section'),
|
2023-03-17 11:01:35 +03:00
|
|
|
|
showCommentCta: newsletter.get('show_comment_cta') && this.#settingsCache.get('comments_enabled') !== 'off' && !hasEmailOnlyFlag,
|
2023-03-24 12:31:48 +03:00
|
|
|
|
showSubscriptionDetails: newsletter.get('show_subscription_details')
|
2022-11-29 13:27:17 +03:00
|
|
|
|
},
|
2023-03-20 16:30:42 +03:00
|
|
|
|
latestPosts,
|
|
|
|
|
latestPostsHasImages,
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
//CSS
|
|
|
|
|
accentColor: accentColor, // default to #15212A
|
|
|
|
|
adjustedAccentColor: adjustedAccentColor || '#3498db', // default to #3498db
|
|
|
|
|
adjustedAccentContrastColor: adjustedAccentContrastColor || '#ffffff', // default to #ffffff
|
|
|
|
|
showBadge: newsletter.get('show_badge'),
|
2023-03-29 07:03:49 +03:00
|
|
|
|
backgroundColor: backgroundColor,
|
|
|
|
|
backgroundIsDark: backgroundIsDark,
|
|
|
|
|
borderColor: borderColor,
|
|
|
|
|
secondaryBorderColor: secondaryBorderColor,
|
|
|
|
|
titleColor: titleColor,
|
|
|
|
|
textColor: textColor,
|
|
|
|
|
secondaryTextColor: secondaryTextColor,
|
|
|
|
|
linkColor: linkColor,
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
|
|
|
|
headerImage,
|
|
|
|
|
headerImageWidth,
|
|
|
|
|
showHeaderIcon: newsletter.get('show_header_icon') && this.#settingsCache.get('icon'),
|
2023-03-14 13:29:43 +03:00
|
|
|
|
|
|
|
|
|
// TODO: consider moving these to newsletter property
|
2022-11-29 13:27:17 +03:00
|
|
|
|
showHeaderTitle: newsletter.get('show_header_title'),
|
|
|
|
|
showHeaderName: newsletter.get('show_header_name'),
|
2023-01-11 14:13:13 +03:00
|
|
|
|
showFeatureImage: newsletter.get('show_feature_image') && !!postFeatureImage,
|
2022-11-29 13:27:17 +03:00
|
|
|
|
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` : ``),
|
2023-03-17 12:05:59 +03:00
|
|
|
|
meta: 'post-meta' + (newsletter.get('title_alignment') === 'left' ? ` post-meta-left` : ` post-meta-center`),
|
2022-11-29 13:27:17 +03:00
|
|
|
|
body: newsletter.get('body_font_category') === 'sans_serif' ? `post-content-sans-serif` : `post-content`
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Audience feedback
|
|
|
|
|
feedbackButtons: newsletter.get('feedback_enabled') ? {
|
|
|
|
|
likeHref: positiveLink,
|
2023-01-09 15:40:42 +03:00
|
|
|
|
dislikeHref: negativeLink
|
2022-11-29 13:27:17 +03:00
|
|
|
|
} : null,
|
|
|
|
|
|
|
|
|
|
// Paywall
|
|
|
|
|
paywall: addPaywall ? {
|
|
|
|
|
signupUrl: signupUrl.href
|
|
|
|
|
} : null,
|
|
|
|
|
|
|
|
|
|
year: new Date().getFullYear().toString()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return data;
|
2022-11-23 13:33:44 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2022-11-29 13:27:17 +03:00
|
|
|
|
* @private
|
|
|
|
|
* Sets and limits the width of an image + returns the width
|
2023-03-22 14:32:45 +03:00
|
|
|
|
* @returns {Promise<{href: string, width: number, height: number | null}>}
|
2022-11-23 13:33:44 +03:00
|
|
|
|
*/
|
2023-03-22 14:32:45 +03:00
|
|
|
|
async limitImageWidth(href, visibleWidth = 600, visibleHeight = null) {
|
2022-11-29 13:27:17 +03:00
|
|
|
|
if (!href) {
|
|
|
|
|
return {
|
|
|
|
|
href,
|
2023-03-22 14:32:45 +03:00
|
|
|
|
width: 0,
|
|
|
|
|
height: null
|
2022-11-29 13:27:17 +03:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (isUnsplashImage(href)) {
|
|
|
|
|
// Unsplash images have a minimum size so assuming 1200px is safe
|
|
|
|
|
const unsplashUrl = new URL(href);
|
2023-03-22 14:32:45 +03:00
|
|
|
|
unsplashUrl.searchParams.delete('w');
|
|
|
|
|
unsplashUrl.searchParams.delete('h');
|
|
|
|
|
|
2023-03-20 16:30:42 +03:00
|
|
|
|
unsplashUrl.searchParams.set('w', (visibleWidth * 2).toFixed(0));
|
2022-11-29 13:27:17 +03:00
|
|
|
|
|
2023-03-22 14:32:45 +03:00
|
|
|
|
if (visibleHeight) {
|
|
|
|
|
unsplashUrl.searchParams.set('h', (visibleHeight * 2).toFixed(0));
|
|
|
|
|
unsplashUrl.searchParams.set('fit', 'crop');
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-29 13:27:17 +03:00
|
|
|
|
return {
|
|
|
|
|
href: unsplashUrl.href,
|
2023-03-22 14:32:45 +03:00
|
|
|
|
width: visibleWidth,
|
|
|
|
|
height: visibleHeight
|
2022-11-29 13:27:17 +03:00
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
const size = await this.#imageSize.getImageSizeFromUrl(href);
|
|
|
|
|
|
2023-03-20 16:30:42 +03:00
|
|
|
|
if (size.width >= visibleWidth) {
|
2023-03-22 14:32:45 +03:00
|
|
|
|
if (!visibleHeight) {
|
|
|
|
|
// Keep aspect ratio
|
|
|
|
|
size.height = Math.round(size.height * (visibleWidth / size.width));
|
|
|
|
|
}
|
2023-03-22 16:17:01 +03:00
|
|
|
|
|
|
|
|
|
// keep original image, just set a fixed width
|
|
|
|
|
size.width = visibleWidth;
|
2023-03-22 14:32:45 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (visibleHeight && size.height >= visibleHeight) {
|
|
|
|
|
// keep original image, just set a fixed width
|
|
|
|
|
size.height = visibleHeight;
|
2022-11-29 13:27:17 +03:00
|
|
|
|
}
|
|
|
|
|
|
2022-12-09 13:17:22 +03:00
|
|
|
|
if (this.#storageUtils.isLocalImage(href)) {
|
2022-11-29 13:27:17 +03:00
|
|
|
|
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
|
|
|
|
|
return {
|
2023-03-22 14:32:45 +03:00
|
|
|
|
href: href.replace(/\/content\/images\//, '/content/images/size/w' + (visibleWidth * 2) + (visibleHeight ? 'h' + (visibleHeight * 2) : '') + '/'),
|
|
|
|
|
width: size.width,
|
|
|
|
|
height: size.height
|
2022-11-29 13:27:17 +03:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
href,
|
2023-03-22 14:32:45 +03:00
|
|
|
|
width: size.width,
|
|
|
|
|
height: size.height
|
2022-11-29 13:27:17 +03:00
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// log and proceed. Using original header image without fixed width isn't fatal.
|
|
|
|
|
logging.error(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
href,
|
2023-03-22 14:32:45 +03:00
|
|
|
|
width: 0,
|
|
|
|
|
height: null
|
2022-11-29 13:27:17 +03:00
|
|
|
|
};
|
2022-11-23 13:33:44 +03:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = EmailRenderer;
|