mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-25 02:31:59 -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(),
|
permissions.init(),
|
||||||
xmlrpc.listen(),
|
xmlrpc.listen(),
|
||||||
slack.listen(),
|
slack.listen(),
|
||||||
|
audienceFeedback.init(),
|
||||||
emailService.init(),
|
emailService.init(),
|
||||||
mega.listen(),
|
mega.listen(),
|
||||||
webhooks.listen(),
|
webhooks.listen(),
|
||||||
|
@ -322,7 +323,6 @@ async function initServices({config}) {
|
||||||
}),
|
}),
|
||||||
comments.init(),
|
comments.init(),
|
||||||
linkTracking.init(),
|
linkTracking.init(),
|
||||||
audienceFeedback.init(),
|
|
||||||
emailSuppressionList.init()
|
emailSuppressionList.init()
|
||||||
]);
|
]);
|
||||||
debug('End: Services');
|
debug('End: Services');
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
const logging = require('@tryghost/logging');
|
const logging = require('@tryghost/logging');
|
||||||
const ObjectID = require('bson-objectid').default;
|
const ObjectID = require('bson-objectid').default;
|
||||||
|
const url = require('../../../server/api/endpoints/utils/serializers/output/utils/url');
|
||||||
|
|
||||||
class EmailServiceWrapper {
|
class EmailServiceWrapper {
|
||||||
|
getPostUrl(post) {
|
||||||
|
const jsonModel = post.toJSON();
|
||||||
|
url.forPost(post.id, jsonModel, {options: {}});
|
||||||
|
return jsonModel.url;
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (this.service) {
|
if (this.service) {
|
||||||
return;
|
return;
|
||||||
|
@ -10,6 +17,7 @@ class EmailServiceWrapper {
|
||||||
const {EmailService, EmailController, EmailRenderer, SendingService, BatchSendingService, EmailSegmenter, EmailEventStorage} = require('@tryghost/email-service');
|
const {EmailService, EmailController, EmailRenderer, SendingService, BatchSendingService, EmailSegmenter, EmailEventStorage} = require('@tryghost/email-service');
|
||||||
const {Post, Newsletter, Email, EmailBatch, EmailRecipient, Member} = require('../../models');
|
const {Post, Newsletter, Email, EmailBatch, EmailRecipient, Member} = require('../../models');
|
||||||
const settingsCache = require('../../../shared/settings-cache');
|
const settingsCache = require('../../../shared/settings-cache');
|
||||||
|
const settingsHelpers = require('../../services/settings-helpers');
|
||||||
const jobsService = require('../jobs');
|
const jobsService = require('../jobs');
|
||||||
const membersService = require('../members');
|
const membersService = require('../members');
|
||||||
const db = require('../../data/db');
|
const db = require('../../data/db');
|
||||||
|
@ -17,11 +25,53 @@ class EmailServiceWrapper {
|
||||||
const limitService = require('../limits');
|
const limitService = require('../limits');
|
||||||
const domainEvents = require('@tryghost/domain-events');
|
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({
|
const sendingService = new SendingService({
|
||||||
emailProvider: {
|
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}`);
|
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()});
|
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() {
|
function getButtonsHeadStyles() {
|
||||||
|
// DEPRECATED!
|
||||||
|
// PLEASE MAKE IDENTICAL CHANGES TO email-service package email-templates/styles.hbs
|
||||||
return (`
|
return (`
|
||||||
.like-icon {
|
.like-icon {
|
||||||
mix-blend-mode: darken;
|
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 {escapeHtml: escape} = require('@tryghost/string');
|
||||||
const feedbackButtons = require('./feedback-buttons');
|
const feedbackButtons = require('./feedback-buttons');
|
||||||
|
|
||||||
|
|
|
@ -8,5 +8,12 @@ describe('EmailServiceWrapper', function () {
|
||||||
|
|
||||||
const service = require('../../../../../core/server/services/email-service');
|
const service = require('../../../../../core/server/services/email-service');
|
||||||
service.init();
|
service.init();
|
||||||
|
|
||||||
|
// Increase test coverage for the wrapper
|
||||||
|
service.getPostUrl({
|
||||||
|
toJSON: () => [{
|
||||||
|
id: '1'
|
||||||
|
}]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -114,7 +114,7 @@ class BatchSendingService {
|
||||||
|
|
||||||
// Load required relations
|
// Load required relations
|
||||||
const newsletter = await email.getLazyRelation('newsletter', {require: true});
|
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);
|
let batches = await this.getBatches(email);
|
||||||
if (batches.length === 0) {
|
if (batches.length === 0) {
|
||||||
|
@ -142,7 +142,7 @@ class BatchSendingService {
|
||||||
async createBatches({email, post, newsletter}) {
|
async createBatches({email, post, newsletter}) {
|
||||||
logging.info(`Creating batches for email ${email.id}`);
|
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 batches = [];
|
||||||
const BATCH_SIZE = 500;
|
const BATCH_SIZE = 500;
|
||||||
let totalCount = 0;
|
let totalCount = 0;
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
/* eslint-disable no-unused-vars */
|
/* 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 {string|null} Segment
|
||||||
* @typedef {object} Post
|
* @typedef {object} Post
|
||||||
|
@ -16,7 +24,8 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {object} ReplacementDefinition
|
* @typedef {object} ReplacementDefinition
|
||||||
* @prop {string} token
|
* @prop {string} id
|
||||||
|
* @prop {RegExp} token
|
||||||
* @prop {(member: MemberLike) => string} getValue
|
* @prop {(member: MemberLike) => string} getValue
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -33,14 +42,143 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class EmailRenderer {
|
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.
|
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 {Post} post
|
||||||
@param {Newsletter} newsletter
|
@returns {Segment[]}
|
||||||
@returns {Promise<Segment[]>}
|
|
||||||
*/
|
*/
|
||||||
async getSegments(post, newsletter) {
|
getSegments(post) {
|
||||||
return [null];
|
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>}
|
* @returns {Promise<EmailBody>}
|
||||||
*/
|
*/
|
||||||
async renderBody(post, newsletter, segment, options) {
|
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 {
|
return {
|
||||||
html: 'HTML',
|
html,
|
||||||
plaintext: 'Plaintext',
|
plaintext,
|
||||||
replacements: []
|
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 unsubscribeUrl.href;
|
||||||
return 'noreply@example.com'; // TODO
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Post} post
|
* @private
|
||||||
* @param {Newsletter} newsletter
|
* Note that we only look in HTML because plaintext and HTML are essentially the same content
|
||||||
* @returns {string|null}
|
* @returns {ReplacementDefinition[]}
|
||||||
*/
|
*/
|
||||||
getReplyToAddress(post, newsletter) {
|
buildReplacementDefinitions({html, newsletter}) {
|
||||||
return 'noreply@example.com'; // TODO
|
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'),
|
track_clicks: !!this.#settingsCache.get('email_track_clicks'),
|
||||||
feedback_enabled: !!newsletter.get('feedback_enabled'),
|
feedback_enabled: !!newsletter.get('feedback_enabled'),
|
||||||
recipient_filter: emailRecipientFilter,
|
recipient_filter: emailRecipientFilter,
|
||||||
subject: this.#emailRenderer.getSubject(post, newsletter),
|
subject: this.#emailRenderer.getSubject(post),
|
||||||
from: this.#emailRenderer.getFromAddress(post, newsletter),
|
from: this.#emailRenderer.getFromAddress(post, newsletter),
|
||||||
replyTo: this.#emailRenderer.getReplyToAddress(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 {
|
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
|
* @typedef {object} Replacement
|
||||||
* @prop {string} token
|
* @prop {string} id
|
||||||
|
* @prop {RegExp} token
|
||||||
* @prop {string} value
|
* @prop {string} value
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -82,7 +83,7 @@ class SendingService {
|
||||||
|
|
||||||
const recipients = this.buildRecipients(members, emailBody.replacements);
|
const recipients = this.buildRecipients(members, emailBody.replacements);
|
||||||
return await this.#emailProvider.send({
|
return await this.#emailProvider.send({
|
||||||
subject: this.#emailRenderer.getSubject(post, newsletter),
|
subject: this.#emailRenderer.getSubject(post),
|
||||||
from: this.#emailRenderer.getFromAddress(post, newsletter),
|
from: this.#emailRenderer.getFromAddress(post, newsletter),
|
||||||
replyTo: this.#emailRenderer.getReplyToAddress(post, newsletter) ?? undefined,
|
replyTo: this.#emailRenderer.getReplyToAddress(post, newsletter) ?? undefined,
|
||||||
html: emailBody.html,
|
html: emailBody.html,
|
||||||
|
@ -103,6 +104,7 @@ class SendingService {
|
||||||
email: member.email,
|
email: member.email,
|
||||||
replacements: replacementDefinitions.map((def) => {
|
replacements: replacementDefinitions.map((def) => {
|
||||||
return {
|
return {
|
||||||
|
id: def.id,
|
||||||
token: def.token,
|
token: def.token,
|
||||||
value: def.getValue(member)
|
value: def.getValue(member)
|
||||||
};
|
};
|
||||||
|
|
|
@ -28,6 +28,12 @@
|
||||||
"@tryghost/tpl": "0.1.19",
|
"@tryghost/tpl": "0.1.19",
|
||||||
"bson-objectid": "2.0.4",
|
"bson-objectid": "2.0.4",
|
||||||
"@tryghost/email-events": "0.0.0",
|
"@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