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:
parent
f4fdb4fa6c
commit
f5045b9bf7
16 changed files with 2160 additions and 38 deletions
|
@ -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');
|
||||
|
|
|
@ -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()});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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'
|
||||
}]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(/'/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 = /^(?<recipientProperty>\w+?)(?:,? *(?:"|")(?<fallback>.*?)(?:"|"))?$/;
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
27
ghost/email-service/lib/email-templates/partials/paywall.hbs
Normal file
27
ghost/email-service/lib/email-templates/partials/paywall.hbs
Normal 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>
|
1142
ghost/email-service/lib/email-templates/partials/styles.hbs
Normal file
1142
ghost/email-service/lib/email-templates/partials/styles.hbs
Normal file
File diff suppressed because it is too large
Load diff
177
ghost/email-service/lib/email-templates/template.hbs
Normal file
177
ghost/email-service/lib/email-templates/template.hbs
Normal 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> </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}} © {{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> </td>
|
||||
</tr>
|
||||
|
||||
<!--[if mso]>
|
||||
</table>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
<![endif]-->
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
|
@ -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)
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
92
ghost/email-service/test/email-renderer.test.js
Normal file
92
ghost/email-service/test/email-renderer.test.js
Normal 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: ''}), '');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue