Removed old email flow (#16349)

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

The old email flow is no longer used since we introduced the email stability flow. This commit removes the related code and tests. The general test coverage decreased a bit as a result, because the old email flow probably had a high test coverage. The new flow is in separate packages, so it couldn't contribute to a higher test coverage (but it does have 100% unit test coverage).
This commit is contained in:
Simon Backx 2023-03-07 16:08:40 +01:00 committed by GitHub
parent 3db434736b
commit 38de815d98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 3256 additions and 6892 deletions

View File

@ -65,7 +65,6 @@ export default class FeatureService extends Service {
@feature('lexicalEditor') lexicalEditor;
@feature('audienceFeedback') audienceFeedback;
@feature('suppressionList') suppressionList;
@feature('emailStability') emailStability;
@feature('webmentions') webmentions;
@feature('webmentionEmails') webmentionEmails;
@feature('emailErrors') emailErrors;

View File

@ -7,7 +7,7 @@
"cobertura"
],
"statements": 59,
"branches": 85,
"branches": 84,
"functions": 50,
"lines": 59,
"include": [

View File

@ -301,7 +301,6 @@ async function initServices({config}) {
const permissions = require('./server/services/permissions');
const xmlrpc = require('./server/services/xmlrpc');
const slack = require('./server/services/slack');
const {mega} = require('./server/services/mega');
const webhooks = require('./server/services/webhooks');
const limits = require('./server/services/limits');
const apiVersionCompatibility = require('./server/services/api-version-compatibility');
@ -346,7 +345,6 @@ async function initServices({config}) {
audienceFeedback.init(),
emailService.init(),
emailAnalytics.init(),
mega.listen(),
webhooks.listen(),
apiVersionCompatibility.init(),
scheduling.init({

View File

@ -1,14 +1,4 @@
const models = require('../../models');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const mega = require('../../services/mega');
const emailService = require('../../services/email-service');
const labs = require('../../../shared/labs');
const messages = {
postNotFound: 'Post not found.'
};
const emailPreview = new mega.EmailPreview();
module.exports = {
docName: 'email_previews',
@ -30,25 +20,7 @@ module.exports = {
],
permissions: true,
async query(frame) {
if (labs.isSet('emailStability')) {
return await emailService.controller.previewEmail(frame);
}
const options = Object.assign(frame.options, {formats: 'html,plaintext', withRelated: ['authors', 'posts_meta']});
const data = Object.assign(frame.data, {status: 'all'});
const model = await models.Post.findOne(data, options);
if (!model) {
throw new errors.NotFoundError({
message: tpl(messages.postNotFound)
});
}
return emailPreview.generateEmailContent(model, {
newsletter: frame.options.newsletter,
memberSegment: frame.options.memberSegment
});
return await emailService.controller.previewEmail(frame);
}
},
sendTestEmail: {
@ -66,20 +38,7 @@ module.exports = {
},
permissions: true,
async query(frame) {
if (labs.isSet('emailStability')) {
return await emailService.controller.sendTestEmail(frame);
}
const options = Object.assign(frame.options, {status: 'all'});
let model = await models.Post.findOne(options, {withRelated: ['authors']});
if (!model) {
throw new errors.NotFoundError({
message: tpl(messages.postNotFound)
});
}
const {emails = [], memberSegment, newsletter = ''} = frame.data;
return await mega.mega.sendTestEmail(model, emails, memberSegment, newsletter);
return await emailService.controller.sendTestEmail(frame);
}
}
};

View File

@ -1,9 +1,7 @@
const models = require('../../models');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const megaService = require('../../services/mega');
const emailService = require('../../services/email-service');
const labs = require('../../../shared/labs');
const emailAnalytics = require('../../services/email-analytics');
const messages = {
@ -63,27 +61,8 @@ module.exports = {
'id'
],
permissions: true,
// (complexity removed with new labs flag)
// eslint-disable-next-line ghost/ghost-custom/max-api-complexity
async query(frame) {
if (labs.isSet('emailStability')) {
return await emailService.controller.retryFailedEmail(frame);
}
const model = await models.Email.findOne(frame.data, frame.options);
if (!model) {
throw new errors.NotFoundError({
message: tpl(messages.emailNotFound)
});
}
if (model.get('status') !== 'failed') {
throw new errors.IncorrectUsageError({
message: tpl(messages.retryNotAllowed)
});
}
return await megaService.mega.retryFailedEmail(model);
return await emailService.controller.retryFailedEmail(frame);
}
},

View File

@ -1,289 +0,0 @@
const _ = require('lodash');
const Promise = require('bluebird');
const moment = require('moment-timezone');
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const logging = require('@tryghost/logging');
const models = require('../../models');
const MailgunClient = require('@tryghost/mailgun-client');
const sentry = require('../../../shared/sentry');
const debug = require('@tryghost/debug')('mega');
const postEmailSerializer = require('../mega/post-email-serializer');
const configService = require('../../../shared/config');
const settingsCache = require('../../../shared/settings-cache');
async function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
const messages = {
error: 'The email service received an error from mailgun and was unable to send.'
};
const mailgunClient = new MailgunClient({config: configService, settings: settingsCache});
/**
* An object representing batch request result
* @typedef { Object } BatchResultBase
* @property { string } data - data that is returned from Mailgun or one which Mailgun was called with
*/
class BatchResultBase {
constructor(id) {
this.id = id;
}
}
class SuccessfulBatch extends BatchResultBase { }
class FailedBatch extends BatchResultBase {
constructor(id, error) {
super(...arguments);
error.originalMessage = error.message;
if (error.statusCode >= 500) {
error.message = 'Email service is currently unavailable - please try again';
} else if (error.statusCode === 401) {
error.message = 'Email failed to send - please verify your credentials';
} else if (error.message && error.message.toLowerCase().includes('dmarc')) {
error.message = 'Unable to send email from domains implementing strict DMARC policies';
} else if (error.message.includes(`'to' parameter is not a valid address`)) {
error.message = 'Recipient is not a valid address';
} else {
error.message = `Email failed to send "${error.originalMessage}" - please verify your email settings`;
}
this.error = error;
}
}
/**
* An email address
* @typedef { string } EmailAddress
*/
/**
* An object representing an email to send
* @typedef { Object } Email
* @property { string } html - The html content of the email
* @property { string } subject - The subject of the email
*/
module.exports = {
BATCH_SIZE: MailgunClient.BATCH_SIZE,
SuccessfulBatch,
FailedBatch,
// accepts an ID rather than an Email model to better support running via a job queue
async processEmail({emailModel, options}) {
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
const emailId = emailModel.get('id');
// get batch IDs via knex to avoid model instantiation
// only fetch pending or failed batches to avoid re-sending previously sent emails
const batchIds = await models.EmailBatch
.getFilteredCollectionQuery({filter: `email_id:${emailId}+status:[pending,failed]`}, knexOptions)
.select('id', 'member_segment');
const batchResults = await Promise.map(batchIds, async ({id: emailBatchId, member_segment: memberSegment}) => {
try {
await this.processEmailBatch({emailBatchId, options, memberSegment});
return new SuccessfulBatch(emailBatchId);
} catch (error) {
return new FailedBatch(emailBatchId, error);
}
}, {concurrency: 2});
const successes = batchResults.filter(response => (response instanceof SuccessfulBatch));
const failures = batchResults.filter(response => (response instanceof FailedBatch));
const emailStatus = failures.length ? 'failed' : 'submitted';
let error;
if (failures.length) {
error = failures[0].error.message;
}
if (error && error.length > 2000) {
error = error.substring(0, 2000);
}
try {
await models.Email.edit({
status: emailStatus,
results: JSON.stringify(successes),
error: error,
error_data: JSON.stringify(failures) // NOTE: need to discuss how we store this
}, {
id: emailModel.id
});
} catch (err) {
sentry.captureException(err);
logging.error(err);
}
return batchResults;
},
// accepts an ID rather than an EmailBatch model to better support running via a job queue
async processEmailBatch({emailBatchId, options, memberSegment}) {
logging.info('[sendEmailJob] Processing email batch ' + emailBatchId);
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
const emailBatchModel = await models.EmailBatch
.findOne({id: emailBatchId}, Object.assign({}, knexOptions, {withRelated: 'email'}));
if (!emailBatchModel) {
throw new errors.IncorrectUsageError({
message: 'Provided email_batch id does not match a known email_batch record',
context: {
id: emailBatchId
}
});
}
if (!['pending','failed'].includes(emailBatchModel.get('status'))) {
throw new errors.IncorrectUsageError({
message: 'Email batches can only be processed when in the "pending" or "failed" state',
context: `Email batch "${emailBatchId}" has state "${emailBatchModel.get('status')}"`
});
}
// Patch to prevent saving the related email model
await emailBatchModel.save({status: 'submitting'}, {...knexOptions, patch: true});
try {
// get recipient rows via knex to avoid costly bookshelf model instantiation
let recipientRows = await models.EmailRecipient.getFilteredCollectionQuery({filter: `batch_id:${emailBatchId}`}, knexOptions);
// For an unknown reason, the returned recipient rows is sometimes an empty array
// refs https://github.com/TryGhost/Team/issues/2246
let counter = 0;
while (recipientRows.length === 0 && counter < 5) {
logging.info('[sendEmailJob] Found zero recipients [retries:' + counter + '] for email batch ' + emailBatchId);
counter += 1;
await sleep(200);
recipientRows = await models.EmailRecipient.getFilteredCollectionQuery({filter: `batch_id:${emailBatchId}`}, knexOptions);
}
if (counter > 0) {
logging.info('[sendEmailJob] Recovered recipients [retries:' + counter + '] for email batch ' + emailBatchId + ' - ' + recipientRows.length + ' recipients found');
}
// Load newsletter data on email
await emailBatchModel.relations.email.getLazyRelation('newsletter', {require: false, ...knexOptions});
// Load post data on email - for content gating on paywall
await emailBatchModel.relations.email.getLazyRelation('post', {require: false, ...knexOptions});
// send the email
const sendResponse = await this.send(emailBatchModel.relations.email.toJSON(), recipientRows, memberSegment);
logging.info('[sendEmailJob] Submitted email batch ' + emailBatchId);
// update batch success status
return await emailBatchModel.save({
status: 'submitted',
provider_id: sendResponse.id.trim().replace(/^<|>$/g, '')
}, Object.assign({}, knexOptions, {patch: true}));
} catch (error) {
logging.info('[sendEmailJob] Failed email batch ' + emailBatchId);
// update batch failed status
await emailBatchModel.save({status: 'failed'}, {...knexOptions, patch: true});
// log any error that didn't come from the provider which would have already logged it
if (!error.code || error.code !== 'BULK_EMAIL_SEND_FAILED') {
let ghostError = new errors.EmailError({
err: error,
code: 'BULK_EMAIL_SEND_FAILED',
message: `Error sending email batch ${emailBatchId}`,
context: error.message
});
sentry.captureException(ghostError);
logging.error(ghostError);
throw ghostError;
}
throw error;
} finally {
// update all email recipients with a processed_at
await models.EmailRecipient
.where({batch_id: emailBatchId})
.save({processed_at: moment()}, Object.assign({}, knexOptions, {autoRefresh: false, patch: true}));
}
},
/**
* @param {Email-like} emailData - The email to send, must be a POJO so emailModel.toJSON() before calling if needed
* @param {EmailRecipient[]} recipients - The recipients to send the email to with their associated data
* @param {string?} memberSegment - The member segment of the recipients
* @returns {Promise<Object>} - {providerId: 'xxx'}
*/
async send(emailData, recipients, memberSegment) {
logging.info(`[sendEmailJob] Sending email batch to ${recipients.length} recipients`);
const mailgunConfigured = mailgunClient.isConfigured();
if (!mailgunConfigured) {
logging.warn('Bulk email has not been configured');
return;
}
const startTime = Date.now();
debug(`sending message to ${recipients.length} recipients`);
// Update email content for this segment before searching replacements
emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment);
// Check all the used replacements in this email
const replacements = postEmailSerializer.parseReplacements(emailData);
// collate static and dynamic data for each recipient ready for provider
const recipientData = {};
const newsletterUuid = emailData.newsletter ? emailData.newsletter.uuid : null;
recipients.forEach((recipient) => {
// static data for every recipient
const data = {
unique_id: recipient.member_uuid,
unsubscribe_url: postEmailSerializer.createUnsubscribeUrl(recipient.member_uuid, {newsletterUuid})
};
// computed properties on recipients - TODO: better way of handling these
recipient.member_first_name = (recipient.member_name || '').split(' ')[0];
// dynamic data from replacements
replacements.forEach(({id, recipientProperty, fallback}) => {
data[id] = recipient[recipientProperty] || fallback || '';
});
recipientData[recipient.member_email] = data;
});
try {
const response = await mailgunClient.send(emailData, recipientData, replacements);
debug(`sent message (${Date.now() - startTime}ms)`);
logging.info(`[sendEmailJob] Sent message (${Date.now() - startTime}ms)`);
return response;
} catch (err) {
let ghostError = new errors.EmailError({
err,
message: tpl(messages.error),
context: `Mailgun Error ${err.error.status}: ${err.error.details}`,
// REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#errors
help: `https://ghost.org/docs/newsletters/#bulk-email-configuration`,
code: 'BULK_EMAIL_SEND_FAILED'
});
sentry.captureException(ghostError);
logging.error(ghostError);
debug(`failed to send message (${Date.now() - startTime}ms)`);
throw ghostError;
}
},
// NOTE: for testing only!
_mailgunClient: mailgunClient
};

View File

@ -1 +0,0 @@
module.exports = require('./bulk-email-processor');

View File

@ -2,7 +2,7 @@ const {promises: fs} = require('fs');
const path = require('path');
const moment = require('moment');
const htmlToPlaintext = require('@tryghost/html-to-plaintext');
const postEmailSerializer = require('../mega/post-email-serializer');
const emailService = require('../email-service');
class CommentsServiceEmails {
constructor({config, logging, models, mailer, settingsCache, settingsHelpers, urlService, urlUtils}) {
@ -95,7 +95,7 @@ class CommentsServiceEmails {
accentColor: this.settingsCache.get('accent_color'),
fromEmail: this.notificationFromAddress,
toEmail: to,
profileUrl: postEmailSerializer.createUnsubscribeUrl(member.get('uuid'), {comments: true})
profileUrl: emailService.renderer.createUnsubscribeUrl(member.get('uuid'), {comments: true})
};
const {html, text} = await this.renderEmailTemplate('new-comment-reply', templateData);

View File

@ -1,54 +0,0 @@
const postEmailSerializer = require('./post-email-serializer');
const models = require('../../models');
class EmailPreview {
/**
* @param {Object} post - Post model object instance
* @param {Object} options
* @param {String} options.newsletter - newsletter slug
* @param {String} options.memberSegment - member segment filter
* @returns {Promise<Object>}
*/
async generateEmailContent(post, {newsletter, memberSegment} = {}) {
let newsletterModel = await post.getLazyRelation('newsletter');
if (!newsletterModel) {
if (newsletter) {
newsletterModel = await models.Newsletter.findOne({slug: newsletter});
} else {
newsletterModel = await models.Newsletter.getDefaultNewsletter();
}
}
let emailContent = await postEmailSerializer.serialize(post, newsletterModel, {
isBrowserPreview: true
});
if (memberSegment) {
emailContent = postEmailSerializer.renderEmailForSegment(emailContent, memberSegment);
}
// Do fake replacements, just like a normal email, but use fallbacks and empty values
const replacements = postEmailSerializer.parseReplacements(emailContent);
replacements.forEach((replacement) => {
emailContent[replacement.format] = emailContent[replacement.format].replace(
replacement.regexp,
replacement.fallback || ''
);
});
// Replace unsubscribe URL (%recipient.unsubscribe_url% replacement)
// We should do this only here because replacements should happen at the very end only, just like when an actual email would be send
const previewUnsubscribeUrl = postEmailSerializer.createUnsubscribeUrl(null);
emailContent.html = emailContent.html.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
emailContent.plaintext = emailContent.plaintext.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
return {
subject: emailContent.subject,
html: emailContent.html,
plaintext: emailContent.plaintext
};
}
}
module.exports = EmailPreview;

View File

@ -1,66 +0,0 @@
const audienceFeedback = require('../audience-feedback');
const templateStrings = {
like: '%{feedback_button_like}%',
dislike: '%{feedback_button_dislike}%'
};
const generateLinks = (postId, uuid, html) => {
const positiveLink = audienceFeedback.service.buildLink(
uuid,
postId,
1
);
const negativeLink = audienceFeedback.service.buildLink(
uuid,
postId,
0
);
html = html.replace(new RegExp(templateStrings.like, 'g'), positiveLink.href);
html = html.replace(new RegExp(templateStrings.dislike, 'g'), negativeLink.href);
return html;
};
const getTemplate = () => {
const likeButtonHtml = getButtonHtml(
templateStrings.like,
'More like this',
'https://static.ghost.org/v5.0.0/images/more-like-this.png'
);
const dislikeButtonHtml = getButtonHtml(
templateStrings.dislike,
'Less like this',
'https://static.ghost.org/v5.0.0/images/less-like-this.png'
);
return (`
<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>
${likeButtonHtml}
${dislikeButtonHtml}
</tr>
</table>
</td>
</tr>
`);
};
function getButtonHtml(href, buttonText, iconUrl) {
return (`
<td dir="ltr" valign="top" align="center" style="vertical-align: top; font-family: inherit; font-size: 14px; text-align: center; padding: 0 8px;" nowrap>
<a href="${href}" target="_blank">
<img src="${iconUrl}" border="0" width="156" height="38" alt="${buttonText}">
</a>
</td>
`);
}
module.exports = {
generateLinks,
getTemplate
};

View File

@ -1,14 +0,0 @@
module.exports = {
get mega() {
return require('./mega');
},
get postEmailSerializer() {
return require('./post-email-serializer');
},
get EmailPreview() {
return require('./email-preview');
}
};

View File

@ -1,626 +0,0 @@
const _ = require('lodash');
const Promise = require('bluebird');
const debug = require('@tryghost/debug')('mega');
const tpl = require('@tryghost/tpl');
const moment = require('moment');
const ObjectID = require('bson-objectid').default;
const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const settingsCache = require('../../../shared/settings-cache');
const membersService = require('../members');
const limitService = require('../limits');
const bulkEmailService = require('../bulk-email');
const jobsService = require('../jobs');
const db = require('../../data/db');
const models = require('../../models');
const postEmailSerializer = require('./post-email-serializer');
const {getSegmentsFromHtml} = require('./segment-parser');
const labs = require('../../../shared/labs');
// Used to listen to email.added and email.edited model events originally, I think to offload this - ideally would just use jobs now if possible
const events = require('../../lib/common/events');
const messages = {
invalidSegment: 'Invalid segment value. Use one of the valid:"status:free" or "status:-free" values.',
unexpectedFilterError: 'Unexpected {property} value "{value}", expected an NQL equivalent',
noneFilterError: 'Cannot send email to "none" {property}',
emailSendingDisabled: `Email sending is temporarily disabled because your account is currently in review. You should have an email about this from us already, but you can also reach us any time at support@ghost.org`,
sendEmailRequestFailed: 'The email service was unable to send an email batch.',
archivedNewsletterError: 'Cannot send email to archived newsletters',
newsletterVisibilityError: 'Unexpected visibility value "{value}". Use one of the valid: "members", "paid".'
};
const getFromAddress = (senderName, fromAddress) => {
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;
};
const getReplyToAddress = (fromAddress, replyAddressOption) => {
const supportAddress = membersService.config.getEmailSupportAddress();
return (replyAddressOption === 'support') ? supportAddress : fromAddress;
};
/**
*
* @param {Object} postModel - post model instance
* @param {Object} options
* @param {Object} options
*/
const getEmailData = async (postModel, options) => {
let newsletter;
if (options.newsletterSlug) {
newsletter = await models.Newsletter.findOne({slug: options.newsletterSlug});
} else {
newsletter = await postModel.getLazyRelation('newsletter');
}
if (!newsletter) {
// The postModel doesn't have a newsletter in test emails
newsletter = await models.Newsletter.getDefaultNewsletter();
}
const {subject, html, plaintext} = await postEmailSerializer.serialize(postModel, newsletter, options);
let senderName = settingsCache.get('title') ? settingsCache.get('title').replace(/"/g, '\\"') : '';
if (newsletter.get('sender_name')) {
senderName = newsletter.get('sender_name');
}
let fromAddress = membersService.config.getEmailFromAddress();
if (newsletter.get('sender_email')) {
fromAddress = newsletter.get('sender_email');
}
return {
post: postModel.toJSON(), // for content paywalling
subject,
html,
plaintext,
from: getFromAddress(senderName, fromAddress),
replyTo: getReplyToAddress(fromAddress, newsletter.get('sender_reply_to'))
};
};
/**
*
* @param {Object} postModel - post model instance
* @param {[string]} toEmails - member email addresses to send email to
* @param {ValidMemberSegment} [memberSegment]
*/
const sendTestEmail = async (postModel, toEmails, memberSegment, newsletterSlug) => {
let emailData = await getEmailData(postModel, {isTestEmail: true, newsletterSlug});
emailData.subject = `[Test] ${emailData.subject}`;
// fetch any matching members so that replacements use expected values
const recipients = await Promise.all(toEmails.map(async (email) => {
const member = await membersService.api.members.get({email});
if (member) {
return {
member_uuid: member.get('uuid'),
member_email: member.get('email'),
member_name: member.get('name')
};
}
return {
member_email: email
};
}));
// enable tracking for previews to match real-world behavior
emailData.track_opens = !!settingsCache.get('email_track_opens');
const response = await bulkEmailService.send(emailData, recipients, memberSegment);
if (response instanceof bulkEmailService.FailedBatch) {
return Promise.reject(response.error);
}
if (response && response[0] && response[0].error) {
return Promise.reject(new errors.EmailError({
statusCode: response[0].error.statusCode,
message: response[0].error.message,
context: response[0].error.originalMessage
}));
}
return response;
};
/**
* transformRecipientFilter
*
* Accepts a filter string, errors on unexpected legacy filter syntax and enforces subscribed:true
*
* @param {Object} newsletter
* @param {string} emailRecipientFilter NQL filter for members
* @param {string} errorProperty
*/
const transformEmailRecipientFilter = (newsletter, emailRecipientFilter, errorProperty) => {
const filter = [`newsletters.id:${newsletter.id}`];
switch (emailRecipientFilter) {
case 'all':
break;
case 'none':
throw new errors.InternalServerError({
message: tpl(messages.noneFilterError, {
property: errorProperty
})
});
default:
filter.push(`(${emailRecipientFilter})`);
break;
}
const visibility = newsletter.get('visibility');
switch (visibility) {
case 'members':
// No need to add a member status filter as the email is available to all members
break;
case 'paid':
filter.push(`status:-free`);
break;
default:
throw new errors.InternalServerError({
message: tpl(messages.newsletterVisibilityError, {
value: visibility
})
});
}
return filter.join('+');
};
/**
* addEmail
*
* Accepts a post model and creates an email record based on it. Only creates one
* record per post
*
* @param {object} postModel Post Model Object
* @param {object} options
*/
const addEmail = async (postModel, options) => {
if (limitService.isLimited('emails')) {
await limitService.errorIfWouldGoOverLimit('emails');
}
if (await membersService.verificationTrigger.checkVerificationRequired()) {
throw new errors.HostLimitError({
message: tpl(messages.emailSendingDisabled)
});
}
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
const filterOptions = {...knexOptions, limit: 1};
const sharedOptions = _.pick(options, ['transacting']);
const newsletter = await postModel.getLazyRelation('newsletter', {require: true, ...sharedOptions});
if (newsletter.get('status') !== 'active') {
// A post might have been scheduled to an archived newsletter.
// Don't send it (people can't unsubscribe any longer).
throw new errors.EmailError({
message: tpl(messages.archivedNewsletterError)
});
}
const emailRecipientFilter = postModel.get('email_recipient_filter');
filterOptions.filter = transformEmailRecipientFilter(newsletter, emailRecipientFilter, 'email_segment');
const startRetrieve = Date.now();
debug('addEmail: retrieving members count');
const {meta: {pagination: {total: membersCount}}} = await membersService.api.members.list({...knexOptions, ...filterOptions});
debug(`addEmail: retrieved members count - ${membersCount} members (${Date.now() - startRetrieve}ms)`);
// NOTE: don't create email object when there's nobody to send the email to
if (membersCount === 0) {
return null;
}
if (limitService.isLimited('emails')) {
await limitService.errorIfWouldGoOverLimit('emails', {addedCount: membersCount});
}
const postId = postModel.get('id');
const existing = await models.Email.findOne({post_id: postId}, knexOptions);
if (!existing) {
// get email contents and perform replacements using no member data so
// we have a decent snapshot of email content for later display
const emailData = await getEmailData(postModel, options);
return models.Email.add({
post_id: postId,
status: 'pending',
email_count: membersCount,
subject: emailData.subject,
from: emailData.from,
reply_to: emailData.replyTo,
html: emailData.html,
source: emailData.html,
source_type: 'html',
plaintext: emailData.plaintext,
submitted_at: moment().toDate(),
track_opens: !!settingsCache.get('email_track_opens'),
track_clicks: !!settingsCache.get('email_track_clicks'),
feedback_enabled: !!newsletter.get('feedback_enabled'),
recipient_filter: emailRecipientFilter,
newsletter_id: newsletter.id
}, knexOptions);
} else {
return existing;
}
};
/**
* retryFailedEmail
*
* Accepts an Email model and resets it's fields to trigger retry listeners
*
* @param {Email} emailModel Email model
*/
const retryFailedEmail = async (emailModel) => {
return await models.Email.edit({
status: 'pending'
}, {
id: emailModel.get('id')
});
};
async function pendingEmailHandler(emailModel, options) {
if (labs.isSet('emailStability')) {
return;
}
// CASE: do not send email if we import a database
// TODO: refactor post.published events to never fire on importing
if (options && options.importing) {
return;
}
if (emailModel.get('status') !== 'pending') {
return;
}
// make sure recurring background analytics jobs are running once we have emails
const emailAnalyticsJobs = require('../email-analytics/jobs');
emailAnalyticsJobs.scheduleRecurringJobs();
// @TODO move this into the jobService
if (!process.env.NODE_ENV.startsWith('test')) {
return jobsService.addJob({
job: sendEmailJob,
data: {emailId: emailModel.id},
offloaded: false
});
}
}
async function sendEmailJob({emailId, options}) {
logging.info('[sendEmailJob] Started for ' + emailId);
let startEmailSend = null;
try {
// Check host limit for allowed member count and throw error if over limit
// - do this even if it's a retry so that there's no way around the limit
if (limitService.isLimited('members')) {
await limitService.errorIfIsOverLimit('members');
}
// Check host limit for disabled emails or going over emails limit
if (limitService.isLimited('emails')) {
await limitService.errorIfWouldGoOverLimit('emails');
}
// Check email verification required
// We need to check this inside the job again
if (await membersService.verificationTrigger.checkVerificationRequired()) {
throw new errors.HostLimitError({
message: tpl(messages.emailSendingDisabled)
});
}
// Check if the email is still pending. And set the status to submitting in one transaction.
let hasSingleAccess = false;
let emailModel;
await models.Base.transaction(async (transacting) => {
const knexOptions = {...options, transacting, forUpdate: true};
emailModel = await models.Email.findOne({id: emailId}, knexOptions);
if (!emailModel) {
throw new errors.IncorrectUsageError({
message: 'Provided email id does not match a known email record',
context: {
id: emailId
}
});
}
if (emailModel.get('status') !== 'pending') {
// We don't throw this, because we don't want to mark this email as failed
logging.error(new errors.IncorrectUsageError({
message: 'Emails can only be processed when in the "pending" state',
context: `Email "${emailId}" has state "${emailModel.get('status')}"`,
code: 'EMAIL_NOT_PENDING'
}));
return;
}
await emailModel.save({status: 'submitting'}, Object.assign({}, knexOptions, {patch: true}));
hasSingleAccess = true;
});
if (!hasSingleAccess || !emailModel) {
return;
}
// Create email batch and recipient rows unless this is a retry and they already exist
const existingBatchCount = await emailModel.related('emailBatches').count('id');
if (existingBatchCount === 0) {
logging.info('[sendEmailJob] Creating new batches for ' + emailId);
let newBatchCount = 0;
await models.Base.transaction(async (transacting) => {
const emailBatches = await createSegmentedEmailBatches({emailModel, options: {transacting}});
newBatchCount = emailBatches.length;
});
if (newBatchCount === 0) {
logging.info('[sendEmailJob] No batches created for ' + emailId);
await emailModel.save({status: 'submitted'}, {patch: true});
return;
}
}
debug('sendEmailJob: sending email');
startEmailSend = Date.now();
await bulkEmailService.processEmail({emailModel, options});
debug(`sendEmailJob: sent email (${Date.now() - startEmailSend}ms)`);
} catch (error) {
if (startEmailSend) {
logging.info(`[sendEmailJob] Failed sending ${emailId} (${Date.now() - startEmailSend}ms)`);
} else {
logging.info(`[sendEmailJob] Failed sending ${emailId}`);
}
if (startEmailSend) {
debug(`sendEmailJob: send email failed (${Date.now() - startEmailSend}ms)`);
}
let errorMessage = error.message;
if (errorMessage.length > 2000) {
errorMessage = errorMessage.substring(0, 2000);
}
await models.Email.edit({
status: 'failed',
error: errorMessage
}, {id: emailId});
throw new errors.InternalServerError({
err: error,
context: tpl(messages.sendEmailRequestFailed)
});
}
}
/**
* Fetch rows of members that should receive an email.
* Uses knex directly rather than bookshelf to avoid thousands of bookshelf model
* instantiations and associated processing and event loop blocking
*
* @param {Object} options
* @param {Object} options.emailModel - instance of Email model
* @param {string} [options.memberSegment] - NQL filter to apply in addition to the one defined in emailModel
* @param {Object} options.options - knex options
*
* @returns {Promise<Object[]>} instances of filtered knex member rows
*/
async function getEmailMemberRows({emailModel, memberSegment, options}) {
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
const sharedOptions = _.pick(options, ['transacting']);
const filterOptions = Object.assign({}, knexOptions);
const newsletter = await emailModel.getLazyRelation('newsletter', {require: true, ...sharedOptions});
const recipientFilter = transformEmailRecipientFilter(newsletter, emailModel.get('recipient_filter'), 'recipient_filter');
filterOptions.filter = recipientFilter;
if (memberSegment) {
filterOptions.filter = `${filterOptions.filter}+${memberSegment}`;
}
const startRetrieve = Date.now();
debug('getEmailMemberRows: retrieving members list');
// select('members.*') is necessary here to avoid duplicate `email` columns in the result set
// without it we do `select *` which pulls in the Stripe customer email too which overrides the member email
const memberRows = await models.Member.getFilteredCollectionQuery(filterOptions).select('members.*').distinct();
debug(`getEmailMemberRows: retrieved members list - ${memberRows.length} members (${Date.now() - startRetrieve}ms)`);
return memberRows;
}
/**
* Partitions array of member records according to the segment they belong to
*
* @param {Object[]} memberRows raw member rows to partition
* @param {string[]} segments segment filters to partition batches by
*
* @returns {Object} partitioned memberRows with keys that correspond segment names
*/
function partitionMembersBySegment(memberRows, segments) {
const partitions = {};
for (const memberSegment of segments) {
let segmentedMemberRows;
// NOTE: because we only support two types of segments at the moment the logic was kept dead simple
// in the future this segmentation should probably be substituted with NQL:
// memberRows.filter(member => nql(memberSegment).queryJSON(member));
if (memberSegment === 'status:free') {
segmentedMemberRows = memberRows.filter(member => member.status === 'free');
memberRows = memberRows.filter(member => member.status !== 'free');
} else if (memberSegment === 'status:-free') {
segmentedMemberRows = memberRows.filter(member => member.status !== 'free');
memberRows = memberRows.filter(member => member.status === 'free');
} else {
throw new errors.ValidationError({
message: tpl(messages.invalidSegment)
});
}
partitions[memberSegment] = segmentedMemberRows;
}
if (memberRows.length) {
partitions.unsegmented = memberRows;
}
return partitions;
}
/**
* Detects segment filters in emailModel's html and creates separate batches per segment
*
* @param {Object} options
* @param {Object} options.emailModel - instance of Email model
* @param {Object} options.options - knex options
*
* @returns {Promise<string[]>}
*/
async function createSegmentedEmailBatches({emailModel, options}) {
let memberRows = await getEmailMemberRows({emailModel, options});
if (!memberRows.length) {
return [];
}
const segments = getSegmentsFromHtml(emailModel.get('html'));
const batchIds = [];
if (segments.length) {
const partitionedMembers = partitionMembersBySegment(memberRows, segments);
for (const partition in partitionedMembers) {
const emailBatchIds = await createEmailBatches({
emailModel,
memberRows: partitionedMembers[partition],
memberSegment: partition === 'unsegmented' ? null : partition,
options
});
batchIds.push(...emailBatchIds);
}
} else {
const emailBatchIds = await createEmailBatches({emailModel, memberRows, options});
batchIds.push(...emailBatchIds);
}
return batchIds;
}
/**
* Store email_batch and email_recipient records for an email.
* Uses knex directly rather than bookshelf to avoid thousands of bookshelf model
* instantiations and associated processing and event loop blocking.
*
* @param {Object} options
* @param {Object} options.emailModel - instance of Email model
* @param {string} [options.memberSegment] - NQL filter to apply in addition to the one defined in emailModel
* @param {Object[]} [options.memberRows] - member rows to be batched
* @param {Object} options.options - knex options
* @returns {Promise<string[]>} - created batch ids
*/
async function createEmailBatches({emailModel, memberRows, memberSegment, options}) {
const storeRecipientBatch = async function (recipients) {
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
const batchModel = await models.EmailBatch.add({
email_id: emailModel.id,
member_segment: memberSegment
}, knexOptions);
const recipientData = [];
recipients.forEach((memberRow) => {
if (!memberRow.id || !memberRow.uuid || !memberRow.email) {
logging.warn(`Member row not included as email recipient due to missing data - id: ${memberRow.id}, uuid: ${memberRow.uuid}, email: ${memberRow.email}`);
return;
}
recipientData.push({
id: ObjectID().toHexString(),
email_id: emailModel.id,
member_id: memberRow.id,
batch_id: batchModel.id,
member_uuid: memberRow.uuid,
member_email: memberRow.email,
member_name: memberRow.name
});
});
const insertQuery = db.knex('email_recipients').insert(recipientData);
if (knexOptions.transacting) {
insertQuery.transacting(knexOptions.transacting);
}
await insertQuery;
return batchModel.id;
};
debug('createEmailBatches: storing recipient list');
const startOfRecipientStorage = Date.now();
let rowsToBatch = memberRows;
const batches = _.chunk(rowsToBatch, bulkEmailService.BATCH_SIZE);
const batchIds = await Promise.mapSeries(batches, storeRecipientBatch);
debug(`createEmailBatches: stored recipient list (${Date.now() - startOfRecipientStorage}ms)`);
logging.info(`[createEmailBatches] stored recipient list (${Date.now() - startOfRecipientStorage}ms)`);
return batchIds;
}
const statusChangedHandler = async (emailModel, options) => {
const emailRetried = emailModel.wasChanged()
&& emailModel.get('status') === 'pending'
&& emailModel.previous('status') === 'failed';
if (emailRetried) {
await pendingEmailHandler(emailModel, options);
}
};
function listen() {
events.on('email.added', (emailModel, options) => pendingEmailHandler(emailModel, options).catch((e) => {
logging.error('Error in email.added event handler');
logging.error(e);
}));
events.on('email.edited', (emailModel, options) => statusChangedHandler(emailModel, options).catch((e) => {
logging.error('Error in email.edited event handler');
logging.error(e);
}));
}
// Public API
module.exports = {
listen,
addEmail,
retryFailedEmail,
sendTestEmail,
// NOTE: below are only exposed for testing purposes
_transformEmailRecipientFilter: transformEmailRecipientFilter,
_partitionMembersBySegment: partitionMembersBySegment,
_getEmailMemberRows: getEmailMemberRows,
_getFromAddress: getFromAddress,
_getReplyToAddress: getReplyToAddress,
_sendEmailJob: sendEmailJob
};
/**
* @typedef {'status:free' | 'status:-free'} ValidMemberSegment
*/

View File

@ -1,559 +0,0 @@
const _ = require('lodash');
const template = require('./template');
const settingsCache = require('../../../shared/settings-cache');
const urlUtils = require('../../../shared/url-utils');
const moment = require('moment-timezone');
const api = require('../../api').endpoints;
const apiFramework = require('@tryghost/api-framework');
const {URL} = require('url');
const mobiledocLib = require('../../lib/mobiledoc');
const lexicalLib = require('../../lib/lexical');
const htmlToPlaintext = require('@tryghost/html-to-plaintext');
const membersService = require('../members');
const {isUnsplashImage} = require('@tryghost/kg-default-cards/lib/utils');
const {textColorForBackgroundColor, darkenToContrastThreshold} = require('@tryghost/color-utils');
const logging = require('@tryghost/logging');
const urlService = require('../../services/url');
const linkReplacer = require('@tryghost/link-replacer');
const linkTracking = require('../link-tracking');
const memberAttribution = require('../member-attribution');
const feedbackButtons = require('./feedback-buttons');
const labs = require('../../../shared/labs');
const storageUtils = require('../../adapters/storage/utils');
const ALLOWED_REPLACEMENTS = ['first_name', 'uuid'];
const PostEmailSerializer = {
// Format a full html document ready for email by inlining CSS, adjusting links,
// and performing any client-specific fixes
formatHtmlForEmail(html) {
const juiceOptions = {inlinePseudoElements: true};
const juice = require('juice');
let juicedHtml = juice(html, juiceOptions);
// convert juiced HTML to a DOM-like interface for further manipulation
// happens after inlining of CSS so we can change element types without worrying about styling
const cheerio = require('cheerio');
const _cheerio = cheerio.load(juicedHtml);
// force all links to open in new tab
_cheerio('a').attr('target', '_blank');
// convert figure and figcaption to div so that Outlook applies margins
_cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
juicedHtml = _cheerio.html();
// Fix any unsupported chars in Outlook
juicedHtml = juicedHtml.replace(/&apos;/g, '&#39;');
juicedHtml = juicedHtml.replace(/→/g, '&rarr;');
juicedHtml = juicedHtml.replace(//g, '&ndash;');
juicedHtml = juicedHtml.replace(/“/g, '&ldquo;');
juicedHtml = juicedHtml.replace(/”/g, '&rdquo;');
return juicedHtml;
},
getSite() {
const publicSettings = settingsCache.getPublic();
return Object.assign({}, publicSettings, {
url: urlUtils.urlFor('home', true),
iconUrl: publicSettings.icon ? urlUtils.urlFor('image', {image: publicSettings.icon}, true) : null
});
},
/**
* 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 = urlUtils.getSiteUrl();
const unsubscribeUrl = new URL(siteUrl);
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
if (uuid) {
unsubscribeUrl.searchParams.set('uuid', uuid);
} else {
unsubscribeUrl.searchParams.set('preview', '1');
}
if (options.newsletterUuid) {
unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid);
}
if (options.comments) {
unsubscribeUrl.searchParams.set('comments', '1');
}
return unsubscribeUrl.href;
},
/**
* createPostSignupUrl
*
* Takes a post object. Returns the url that should be used to signup from newsletter
*
* @param {Object} post post object
*/
createPostSignupUrl(post) {
let url = urlService.getUrlByResourceId(post.id, {absolute: true});
// For email-only posts, use site url as base
if (post.status !== 'published' && url.match(/\/404\//)) {
url = urlUtils.getSiteUrl();
}
const signupUrl = new URL(url);
signupUrl.hash = `/portal/signup`;
return signupUrl.href;
},
/**
* replaceFeedbackLinks
*
* Replace the button template links with real links
*
* @param {string} html
* @param {string} postId (will be url encoded)
* @param {string} memberUuid member uuid to use in the URL (will be url encoded)
*/
replaceFeedbackLinks(html, postId, memberUuid) {
return feedbackButtons.generateLinks(postId, memberUuid, html);
},
// NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
async serializePostModel(model) {
// fetch mobiledoc/lexical rather than html and plaintext so we can render email-specific contents
const frame = {options: {context: {user: true}, formats: 'mobiledoc,lexical'}};
const docName = 'posts';
await apiFramework
.serializers
.handle
.output(model, {docName: docName, method: 'read'}, api.serializers.output, frame);
return frame.response[docName][0];
},
// removes %% wrappers from unknown replacement strings in email content
normalizeReplacementStrings(email) {
// we don't want to modify the email object in-place
const emailContent = _.pick(email, ['html', 'plaintext']);
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
['html', 'plaintext'].forEach((format) => {
emailContent[format] = emailContent[format].replace(EMAIL_REPLACEMENT_REGEX, (replacementMatch, replacementStr) => {
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
if (match) {
const {recipientProperty} = match.groups;
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
// keeps wrapping %% for later replacement with real data
return replacementMatch;
}
}
// removes %% so output matches user supplied content
return replacementStr;
});
});
return emailContent;
},
/**
* Parses email content and extracts an array of replacements with desired fallbacks
*
* @param {Object} email
* @param {string} email.html
* @param {string} email.plaintext
*
* @returns {Object[]} replacements
*/
parseReplacements(email) {
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const replacements = [];
['html', 'plaintext'].forEach((format) => {
let result;
while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) {
const [replacementMatch, replacementStr] = result;
// Did we already found this match and added it to the replacements array?
if (replacements.find(r => r.match === replacementMatch && r.format === format)) {
continue;
}
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
if (match) {
const {recipientProperty, fallback} = match.groups;
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
const id = `replacement_${replacements.length + 1}`;
replacements.push({
format,
id,
match: replacementMatch,
regexp: new RegExp(escapeRegExp(replacementMatch), 'g'),
recipientProperty: `member_${recipientProperty}`,
fallback
});
}
}
}
});
return replacements;
},
async getTemplateSettings(newsletter) {
const accentColor = settingsCache.get('accent_color');
const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
const templateSettings = {
headerImage: newsletter.get('header_image'),
showHeaderIcon: newsletter.get('show_header_icon') && settingsCache.get('icon'),
showHeaderTitle: newsletter.get('show_header_title'),
showFeatureImage: newsletter.get('show_feature_image'),
titleFontCategory: newsletter.get('title_font_category'),
titleAlignment: newsletter.get('title_alignment'),
bodyFontCategory: newsletter.get('body_font_category'),
showBadge: newsletter.get('show_badge'),
feedbackEnabled: newsletter.get('feedback_enabled') && labs.isSet('audienceFeedback'),
footerContent: newsletter.get('footer_content'),
showHeaderName: newsletter.get('show_header_name'),
accentColor,
adjustedAccentColor,
adjustedAccentContrastColor
};
if (templateSettings.headerImage) {
if (isUnsplashImage(templateSettings.headerImage)) {
// Unsplash images have a minimum size so assuming 1200px is safe
const unsplashUrl = new URL(templateSettings.headerImage);
unsplashUrl.searchParams.set('w', '1200');
templateSettings.headerImage = unsplashUrl.href;
templateSettings.headerImageWidth = 600;
} else {
const {imageSize} = require('../../lib/image');
try {
const size = await imageSize.getImageSizeFromUrl(templateSettings.headerImage);
if (size.width >= 600) {
// keep original image, just set a fixed width
templateSettings.headerImageWidth = 600;
}
if (storageUtils.isLocalImage(templateSettings.headerImage)) {
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
templateSettings.headerImage = templateSettings.headerImage.replace(/\/content\/images\//, '/content/images/size/w1200/');
}
} catch (err) {
// log and proceed. Using original header image without fixed width isn't fatal.
logging.error(err);
}
}
}
return templateSettings;
},
async serialize(postModel, newsletter, options = {isBrowserPreview: false, isTestEmail: false}) {
const post = await this.serializePostModel(postModel);
const timezone = settingsCache.get('timezone');
const momentDate = post.published_at ? moment(post.published_at) : moment();
post.published_at = momentDate.tz(timezone).format('DD MMM YYYY');
if (post.authors) {
if (post.authors.length <= 2) {
post.authors = post.authors.map(author => author.name).join(' & ');
} else if (post.authors.length > 2) {
post.authors = `${post.authors[0].name} & ${post.authors.length - 1} others`;
}
}
if (post.posts_meta) {
post.email_subject = post.posts_meta.email_subject;
}
// we use post.excerpt as a hidden piece of text that is picked up by some email
// clients as a "preview" when listing emails. Our current plaintext/excerpt
// generation outputs links as "Link [https://url/]" which isn't desired in the preview
if (!post.custom_excerpt && post.excerpt) {
post.excerpt = post.excerpt.replace(/\s\[http(.*?)\]/g, '');
}
if (post.lexical) {
post.html = lexicalLib.render(
post.lexical, {target: 'email', postUrl: post.url}
);
} else {
post.html = mobiledocLib.mobiledocHtmlRenderer.render(
JSON.parse(post.mobiledoc), {target: 'email', postUrl: post.url}
);
}
// perform any email specific adjustments to the HTML render output.
// body wrapper is required so we can get proper top-level selections
const cheerio = require('cheerio');
const _cheerio = cheerio.load(`<body>${post.html}</body>`);
// remove leading/trailing HRs
_cheerio(`
body > hr:first-child,
body > hr:last-child,
body > div:first-child > hr:first-child,
body > div:last-child > hr:last-child
`).remove();
post.html = _cheerio('body').html(); // () (added this comment because of a bug in the syntax highlighter in VSCode)
// Note: we don't need to do link replacements on the plaintext here
// because the plaintext will get recalculated on the updated post html (which already includes link replacements) in renderEmailForSegment
post.plaintext = htmlToPlaintext.email(post.html);
// Outlook will render feature images at full-size breaking the layout.
// Content images fix this by rendering max 600px images - do the same for feature image here
if (post.feature_image) {
if (isUnsplashImage(post.feature_image)) {
// Unsplash images have a minimum size so assuming 1200px is safe
const unsplashUrl = new URL(post.feature_image);
unsplashUrl.searchParams.set('w', '1200');
post.feature_image = unsplashUrl.href;
post.feature_image_width = 600;
} else {
const {imageSize} = require('../../lib/image');
try {
const size = await imageSize.getImageSizeFromUrl(post.feature_image);
if (size.width >= 600) {
// keep original image, just set a fixed width
post.feature_image_width = 600;
}
if (storageUtils.isLocalImage(post.feature_image)) {
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
post.feature_image = post.feature_image.replace(/\/content\/images\//, '/content/images/size/w1200/');
}
} catch (err) {
// log and proceed. Using original feature_image without fixed width isn't fatal.
logging.error(err);
}
}
}
const templateSettings = await this.getTemplateSettings(newsletter);
const render = template;
let htmlTemplate = render({post, site: this.getSite(), templateSettings, newsletter: newsletter.toJSON()});
// The plaintext version that is returned here is actually never really used for sending because we'll use htmlToPlaintext again later
let result = {
html: this.formatHtmlForEmail(htmlTemplate),
plaintext: post.plaintext
};
/**
* If a part of the email is members-only and the post is paid-only, add a paywall:
* - Just before sending the email, we'll hide the paywall or paid content depending on the member segment it is sent to.
* - We already need to do URL-replacement on the HTML here
* - Link replacement cannot happen later because renderEmailForSegment is called multiple times for a single email (which would result in duplicate redirects)
*/
const isPaidPost = post.visibility === 'paid' || post.visibility === 'tiers';
const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
if (paywallIndex !== -1 && isPaidPost) {
const postContentEndIdx = result.html.indexOf('<!-- POST CONTENT END -->');
if (postContentEndIdx !== -1) {
const paywallHTML = '<!-- PAYWALL -->' + this.renderPaywallCTA(post);
// Append it just before the end of the post content
result.html = result.html.slice(0, postContentEndIdx) + paywallHTML + result.html.slice(postContentEndIdx);
}
}
// Now replace the links in the HTML version
if (!options.isBrowserPreview && !options.isTestEmail && settingsCache.get('email_track_clicks')) {
result.html = await linkReplacer.replace(result.html, async (url) => {
// Add newsletter source attribution
const isSite = urlUtils.isSiteUrl(url);
if (isSite) {
// Add newsletter name as ref to the URL
url = memberAttribution.outboundLinkTagger.addToUrl(url, newsletter);
// Only add post attribution to our own site (because external sites could/should not process this information)
url = memberAttribution.service.addPostAttributionTracking(url, post);
} else {
// Add email source attribution without the newsletter name
url = memberAttribution.outboundLinkTagger.addToUrl(url);
}
// Add link click tracking
url = await 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;
});
}
// Add buttons
if (labs.isSet('audienceFeedback')) {
// create unique urls for every recipient (for example, for feedback buttons)
// Note, we need to use a different member uuid in the links because `%%{uuid}%%` would get escaped by the URL object when set as a search param
const urlSafeToken = '--' + new Date().getTime() + 'url-safe-uuid--';
result.html = this.replaceFeedbackLinks(result.html, post.id, urlSafeToken).replace(new RegExp(urlSafeToken, 'g'), '%%{uuid}%%');
}
// Clean up any unknown replacements strings to get our final content
const {html, plaintext} = this.normalizeReplacementStrings(result);
const data = {
subject: post.email_subject || post.title,
html,
plaintext
};
// Add post for checking access in renderEmailForSegment (only for previews)
data.post = post;
return data;
},
/**
* renderPaywallCTA
*
* outputs html for rendering paywall CTA in newsletter
*
* @param {Object} post Post Object
*/
renderPaywallCTA(post) {
const accentColor = settingsCache.get('accent_color');
const siteTitle = settingsCache.get('title') || 'Ghost';
const signupUrl = this.createPostSignupUrl(post);
return `<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 ${siteTitle} 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="${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>`;
},
renderEmailForSegment(email, memberSegment) {
const cheerio = require('cheerio');
const result = {...email};
// Note about link tracking:
// Don't add new HTML in here, but add it in the serialize method and surround it with the required HTML comments or attributes
// This is because we can't replace links at this point (this is executed multiple times, once per batch and we don't want to generate duplicate links for the same email)
// Remove the paywall or members-only content based on the current member segment
const startMembersOnlyContent = (result.html || '').indexOf('<!--members-only-->');
const startPaywall = result.html.indexOf('<!-- PAYWALL -->');
let endPost = result.html.indexOf('<!-- POST CONTENT END -->');
if (endPost === -1) {
// Default to the end of the HTML (shouldn't happen, but just in case if we have members-only content that should get removed)
endPost = result.html.length;
}
// We support the cases where there is no <!--members-only--> but there is a paywall (in case of bugs)
// We also support the case where there is no <!-- PAYWALL --> but there is a <!--members-only--> (in case of bugs)
if (startMembersOnlyContent !== -1 || startPaywall !== -1) {
// By default remove the paywall if no memberSegment is passed
let memberHasAccess = true;
if (memberSegment && result.post) {
let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
const postVisiblity = result.post.visibility;
// For newsletter paywall, specific tiers visibility is considered on par to paid tiers
result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
}
if (!memberHasAccess) {
if (startMembersOnlyContent !== -1) {
// Remove the members-only content, but keep the paywall (if there is a paywall)
result.html = result.html.slice(0, startMembersOnlyContent) + result.html.slice(startPaywall === -1 ? endPost : startPaywall);
}
} else {
if (startPaywall !== -1) {
// Remove the paywall
result.html = result.html.slice(0, startPaywall) + result.html.slice(endPost);
}
}
}
const $ = cheerio.load(result.html);
$('[data-gh-segment]').get().forEach((node) => {
if (node.attribs['data-gh-segment'] !== memberSegment) { //TODO: replace with NQL interpretation
$(node).remove();
} else {
// Getting rid of the attribute for a cleaner html output
$(node).removeAttr('data-gh-segment');
}
});
result.html = this.formatHtmlForEmail($.html());
result.plaintext = htmlToPlaintext.email(result.html);
delete result.post;
return result;
}
};
module.exports = {
serialize: PostEmailSerializer.serialize.bind(PostEmailSerializer),
createUnsubscribeUrl: PostEmailSerializer.createUnsubscribeUrl.bind(PostEmailSerializer),
createPostSignupUrl: PostEmailSerializer.createPostSignupUrl.bind(PostEmailSerializer),
renderEmailForSegment: PostEmailSerializer.renderEmailForSegment.bind(PostEmailSerializer),
parseReplacements: PostEmailSerializer.parseReplacements.bind(PostEmailSerializer),
// Export for tests
_getTemplateSettings: PostEmailSerializer.getTemplateSettings.bind(PostEmailSerializer),
_PostEmailSerializer: PostEmailSerializer
};

View File

@ -1,20 +0,0 @@
const getSegmentsFromHtml = (html) => {
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']);
}
// only return unique elements
return [...new Set(allSegments)];
};
module.exports.getSegmentsFromHtml = getSegmentsFromHtml;

File diff suppressed because it is too large Load Diff

View File

@ -8,8 +8,7 @@ const messages = {
};
class PostsService {
constructor({mega, urlUtils, models, isSet, stats, emailService}) {
this.mega = mega;
constructor({urlUtils, models, isSet, stats, emailService}) {
this.urlUtils = urlUtils;
this.models = models;
this.isSet = isSet;
@ -45,17 +44,9 @@ class PostsService {
let email;
if (!postEmail) {
if (this.isSet('emailStability')) {
email = await this.emailService.createEmail(model);
} else {
email = await this.mega.addEmail(model, frame.options);
}
email = await this.emailService.createEmail(model);
} else if (postEmail && postEmail.get('status') === 'failed') {
if (this.isSet('emailStability')) {
email = await this.emailService.retryEmail(postEmail);
} else {
email = await this.mega.retryFailedEmail(postEmail);
}
email = await this.emailService.retryEmail(postEmail);
}
if (email) {
model.set('email', email);
@ -130,7 +121,6 @@ class PostsService {
*/
const getPostServiceInstance = () => {
const urlUtils = require('../../../shared/url-utils');
const {mega} = require('../mega');
const labs = require('../../../shared/labs');
const models = require('../../models');
const PostStats = require('./stats/post-stats');
@ -139,7 +129,6 @@ const getPostServiceInstance = () => {
const postStats = new PostStats();
return new PostsService({
mega: mega,
urlUtils: urlUtils,
models: models,
isSet: flag => labs.isSet(flag), // don't use bind, that breaks test subbing of labs

View File

@ -20,7 +20,6 @@ const GA_FEATURES = [
'memberAttribution',
'audienceFeedback',
'themeErrorsNotification',
'emailStability',
'emailErrors',
'outboundLinkTagging'
];

View File

@ -220,7 +220,6 @@
"eslint": "8.35.0",
"expect": "29.3.1",
"form-data": "4.0.0",
"html-validate": "7.13.2",
"inquirer": "8.2.5",
"jwks-rsa": "3.0.1",
"mocha": "10.2.0",

View File

@ -654,7 +654,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3682",
"content-length": "3658",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -1,7 +1,9 @@
const {agentProvider, fixtureManager, matchers, mockManager} = require('../../utils/e2e-framework');
const {anyEtag, anyErrorId, anyContentVersion} = matchers;
const {anyEtag, anyErrorId, anyContentVersion, anyString} = matchers;
const assert = require('assert');
const sinon = require('sinon');
const escapeRegExp = require('lodash/escapeRegExp');
const should = require('should');
// @TODO: factor out these requires
const ObjectId = require('bson-objectid').default;
@ -9,18 +11,34 @@ const testUtils = require('../../utils');
const models = require('../../../core/server/models/index');
const logging = require('@tryghost/logging');
async function testCleanedSnapshot(html, cleaned) {
for (const [key, value] of Object.entries(cleaned)) {
html = html.replace(new RegExp(escapeRegExp(key), 'g'), value);
}
should({html}).matchSnapshot();
}
const matchEmailPreviewBody = {
email_previews: [
{
html: anyString,
plaintext: anyString
}
]
};
describe('Email Preview API', function () {
let agent;
beforeEach(function () {
mockManager.mockLabsDisabled('emailStability');
});
afterEach(function () {
mockManager.restore();
sinon.restore();
});
beforeEach(function () {
mockManager.mockMailgun();
});
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('users', 'newsletters', 'posts');
@ -50,7 +68,7 @@ describe('Email Preview API', function () {
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot();
.matchBodySnapshot(matchEmailPreviewBody);
});
it('can read post email preview with email card and replacements', async function () {
@ -75,10 +93,11 @@ describe('Email Preview API', function () {
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot();
.matchBodySnapshot(matchEmailPreviewBody);
});
it('has custom content transformations for email compatibility', async function () {
const defaultNewsletter = await models.Newsletter.getDefaultNewsletter();
const post = testUtils.DataGenerator.forKnex.createPost({
id: ObjectId().toHexString(),
title: 'Post with email-only card',
@ -100,11 +119,15 @@ describe('Email Preview API', function () {
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot()
.matchBodySnapshot(matchEmailPreviewBody)
.expect(({body}) => {
// Extra assert to ensure apostrophe is transformed
assert.doesNotMatch(body.email_previews[0].html, /Testing links in email excerpt and apostrophes &apos;/);
assert.match(body.email_previews[0].html, /Testing links in email excerpt and apostrophes &#39;/);
testCleanedSnapshot(body.email_previews[0].plaintext, {
[defaultNewsletter.get('uuid')]: 'requested-newsletter-uuid'
});
});
});
@ -135,11 +158,17 @@ describe('Email Preview API', function () {
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot()
.matchBodySnapshot(matchEmailPreviewBody)
.expect(({body}) => {
// Extra assert to ensure newsletter is correct
assert.doesNotMatch(body.email_previews[0].html, new RegExp(defaultNewsletter.get('name')));
assert.match(body.email_previews[0].html, new RegExp(selectedNewsletter.name));
testCleanedSnapshot(body.email_previews[0].html, {
[selectedNewsletter.uuid]: 'requested-newsletter-uuid'
});
testCleanedSnapshot(body.email_previews[0].plaintext, {
[selectedNewsletter.uuid]: 'requested-newsletter-uuid'
});
});
});
@ -170,11 +199,17 @@ describe('Email Preview API', function () {
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot()
.matchBodySnapshot(matchEmailPreviewBody)
.expect(({body}) => {
// Extra assert to ensure newsletter is correct
assert.doesNotMatch(body.email_previews[0].html, new RegExp(defaultNewsletter.get('name')));
assert.match(body.email_previews[0].html, new RegExp(selectedNewsletter.name));
testCleanedSnapshot(body.email_previews[0].html, {
[selectedNewsletter.uuid]: 'requested-newsletter-uuid'
});
testCleanedSnapshot(body.email_previews[0].plaintext, {
[selectedNewsletter.uuid]: 'requested-newsletter-uuid'
});
});
});
});

View File

@ -33,7 +33,9 @@ describe('Posts API', function () {
});
beforeEach(function () {
mockManager.mockLabsDisabled('emailStability');
mockManager.mockMailgun();
// Disable network to prevent sending webmentions
mockManager.disableNetwork();
});
afterEach(function () {
@ -834,7 +836,7 @@ describe('Posts API', function () {
should.exist(email);
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
it('Interprets sent as published for a post with email', async function () {
@ -907,7 +909,7 @@ describe('Posts API', function () {
should.exist(email);
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
it('Can publish an email_only post by setting status to published', async function () {
@ -979,7 +981,7 @@ describe('Posts API', function () {
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('recipient_filter')).eql('all');
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
it('Can publish an email_only post with free filter', async function () {
@ -1050,7 +1052,7 @@ describe('Posts API', function () {
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('recipient_filter')).eql('status:free');
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
it('Can publish an email_only post by setting the status to sent', async function () {
@ -1121,7 +1123,7 @@ describe('Posts API', function () {
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('recipient_filter')).eql('status:free');
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
it('Can publish a scheduled post', async function () {
@ -1216,7 +1218,7 @@ describe('Posts API', function () {
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('recipient_filter')).eql('all');
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
it('Can publish a scheduled post with custom email segment', async function () {
@ -1309,7 +1311,7 @@ describe('Posts API', function () {
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('recipient_filter')).eql('status:free');
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
it('Can publish a scheduled post without newsletter', async function () {
@ -1496,7 +1498,7 @@ describe('Posts API', function () {
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('recipient_filter')).eql('all');
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
it('Can\'t change the newsletter once it has been sent', async function () {
@ -1562,7 +1564,7 @@ describe('Posts API', function () {
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('recipient_filter')).eql('status:-free');
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
const unpublished = {
status: 'draft',
@ -1648,160 +1650,6 @@ describe('Posts API', function () {
should(model.get('newsletter_id')).eql(newsletterId);
});
it('Can change the newsletter if it has not been sent', async function () {
// Note: this test only works if there are NO members subscribed to the initial newsletter
// (so it will get reset when changing the post status to draft again)
let model;
const post = {
title: 'My post that will get a changed newsletter',
status: 'draft',
feature_image_alt: 'Testing newsletter',
feature_image_caption: 'Testing <b>feature image caption</b>',
mobiledoc: testUtils.DataGenerator.markdownToMobiledoc('my post'),
created_at: moment().subtract(2, 'days').toDate(),
updated_at: moment().subtract(2, 'days').toDate(),
created_by: ObjectId().toHexString(),
updated_by: ObjectId().toHexString()
};
const res = await request.post(localUtils.API.getApiQuery('posts'))
.set('Origin', config.get('url'))
.send({posts: [post]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201);
const id = res.body.posts[0].id;
// Check default values
should(res.body.posts[0].newsletter).eql(null);
should(res.body.posts[0].email_segment).eql('all');
const newsletterId = testUtils.DataGenerator.Content.newsletters[0].id;
const newsletterSlug = testUtils.DataGenerator.Content.newsletters[0].slug;
const newsletterId2 = testUtils.DataGenerator.Content.newsletters[1].id;
const newsletterSlug2 = testUtils.DataGenerator.Content.newsletters[1].slug;
const updatedPost = {
status: 'published',
updated_at: res.body.posts[0].updated_at
};
const res2 = await request
.put(localUtils.API.getApiQuery('posts/' + id + '/?email_segment=id:0&newsletter=' + newsletterSlug))
.set('Origin', config.get('url'))
.send({posts: [updatedPost]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200);
// Check newsletter relation is loaded in response
should(res2.body.posts[0].newsletter.id).eql(newsletterId);
should(res2.body.posts[0].email_segment).eql('id:0');
should.not.exist(res2.body.posts[0].newsletter_id);
model = await models.Post.findOne({
id: id,
status: 'published'
}, testUtils.context.internal);
should(model.get('newsletter_id')).eql(newsletterId);
should(model.get('email_recipient_filter')).eql('id:0');
// Check email is sent to the correct newsletter
let email = await models.Email.findOne({
post_id: id
}, testUtils.context.internal);
should(email).eql(null);
const unpublished = {
status: 'draft',
updated_at: res2.body.posts[0].updated_at
};
const res3 = await request
.put(localUtils.API.getApiQuery('posts/' + id + '/'))
.set('Origin', config.get('url'))
.send({posts: [unpublished]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200);
// Check is reset
should(res3.body.posts[0].newsletter).eql(null);
should.not.exist(res3.body.posts[0].newsletter_id);
should(res3.body.posts[0].email_segment).eql('all');
model = await models.Post.findOne({
id: id,
status: 'draft'
}, testUtils.context.internal);
should(model.get('newsletter_id')).eql(null);
should(model.get('email_recipient_filter')).eql('all');
const republished = {
status: 'published',
updated_at: res3.body.posts[0].updated_at
};
const res4 = await request
.put(localUtils.API.getApiQuery('posts/' + id + '/?email_segment=status:-free&newsletter=' + newsletterSlug2))
.set('Origin', config.get('url'))
.send({posts: [republished]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200);
// Check newsletter relation is loaded in response
// + did update the newsletter id
should(res4.body.posts[0].newsletter.id).eql(newsletterId2);
should(res4.body.posts[0].email_segment).eql('status:-free');
should.not.exist(res4.body.posts[0].newsletter_id);
model = await models.Post.findOne({
id: id,
status: 'published'
}, testUtils.context.internal);
should(model.get('newsletter_id')).eql(newsletterId2);
should(model.get('email_recipient_filter')).eql('status:-free');
// Check email is sent to the correct newsletter
email = await models.Email.findOne({
post_id: id
}, testUtils.context.internal);
should(email.get('newsletter_id')).eql(newsletterId2);
should(email.get('recipient_filter')).eql('status:-free');
should(email.get('status')).eql('pending');
// Should not change if status remains published
const res5 = await request
.put(localUtils.API.getApiQuery('posts/' + id + '/?newsletter=' + newsletterSlug))
.set('Origin', config.get('url'))
.send({posts: [republished]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200);
// Check newsletter relation is loaded in response
// + did not update the newsletter id
should(res5.body.posts[0].newsletter.id).eql(newsletterId2);
should(res5.body.posts[0].email_segment).eql('status:-free');
should.not.exist(res5.body.posts[0].newsletter_id);
model = await models.Post.findOne({
id: id,
status: 'published'
}, testUtils.context.internal);
// Test if the newsletter_id option was ignored
should(model.get('newsletter_id')).eql(newsletterId2);
should(model.get('email_recipient_filter')).eql('status:-free');
});
it('Cannot get post via pages endpoint', async function () {
await request.get(localUtils.API.getApiQuery(`pages/${testUtils.DataGenerator.Content.posts[3].id}/`))
.set('Origin', config.get('url'))
@ -1898,7 +1746,7 @@ describe('Posts API', function () {
should.exist(email);
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
it('Can publish an email_only post', async function () {
@ -1964,7 +1812,7 @@ describe('Posts API', function () {
should(email.get('newsletter_id')).eql(newsletterId);
should(email.get('recipient_filter')).eql('all');
should(email.get('status')).eql('pending');
should(email.get('status')).equalOneOf('pending', 'submitted', 'submitting');
});
});

View File

@ -2,21 +2,21 @@ const assert = require('assert');
const fetch = require('node-fetch').default;
const {agentProvider, mockManager, fixtureManager} = require('../utils/e2e-framework');
const urlUtils = require('../../core/shared/url-utils');
const jobService = require('../../core/server/services/jobs/job-service');
// @NOTE: this test suite cannot be run in isolation - most likely because it needs
// to have full frontend part of Ghost initialized, not just the backend
describe('Click Tracking', function () {
let agent;
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
const {adminAgent} = await agentProvider.getAgentsWithFrontend();
agent = adminAgent;
await fixtureManager.init('newsletters', 'members:newsletters');
await agent.loginAsOwner();
});
beforeEach(function () {
mockManager.mockLabsDisabled('emailStability');
mockManager.mockMail();
mockManager.mockMailgun();
});
afterEach(function () {
@ -45,6 +45,9 @@ describe('Click Tracking', function () {
}
);
// Wait for the newsletter to be sent
await jobService.allSettled();
const {body: {links}} = await agent.get(
`/links/?filter=post_id:${post.id}`
);

View File

@ -1,313 +0,0 @@
require('should');
const {agentProvider, fixtureManager, mockManager} = require('../../utils/e2e-framework');
const moment = require('moment');
const ObjectId = require('bson-objectid').default;
const models = require('../../../core/server/models');
const sinon = require('sinon');
const assert = require('assert');
const DomainEvents = require('@tryghost/domain-events');
let agent;
async function createPublishedPostEmail() {
const post = {
title: 'A random test post',
status: 'draft',
feature_image_alt: 'Testing sending',
feature_image_caption: 'Testing <b>feature image caption</b>',
created_at: moment().subtract(2, 'days').toISOString(),
updated_at: moment().subtract(2, 'days').toISOString(),
created_by: ObjectId().toHexString(),
updated_by: ObjectId().toHexString()
};
const res = await agent.post('posts/')
.body({posts: [post]})
.expectStatus(201);
const id = res.body.posts[0].id;
const updatedPost = {
status: 'published',
updated_at: res.body.posts[0].updated_at
};
const newsletterSlug = fixtureManager.get('newsletters', 0).slug;
await agent.put(`posts/${id}/?newsletter=${newsletterSlug}`)
.body({posts: [updatedPost]})
.expectStatus(200);
const emailModel = await models.Email.findOne({
post_id: id
});
should.exist(emailModel);
return emailModel;
}
describe('MEGA', function () {
let _sendEmailJob;
let _mailgunClient;
let frontendAgent;
beforeEach(function () {
mockManager.mockLabsDisabled('emailStability');
});
afterEach(function () {
mockManager.restore();
});
describe('sendEmailJob', function () {
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('newsletters', 'members:newsletters');
await agent.loginAsOwner();
_sendEmailJob = require('../../../core/server/services/mega/mega')._sendEmailJob;
_mailgunClient = require('../../../core/server/services/bulk-email')._mailgunClient;
});
it('Can send a scheduled post email', async function () {
sinon.stub(_mailgunClient, 'getInstance').returns({});
sinon.stub(_mailgunClient, 'send').callsFake(async () => {
return {
id: 'stubbed-email-id'
};
});
// Prepare a post and email model
const emailModel = await createPublishedPostEmail();
// Launch email job
await _sendEmailJob({emailId: emailModel.id, options: {}});
await emailModel.refresh();
emailModel.get('status').should.eql('submitted');
});
it('Protects the email job from being run multiple times at the same time', async function () {
sinon.stub(_mailgunClient, 'getInstance').returns({});
sinon.stub(_mailgunClient, 'send').callsFake(async () => {
return {
id: 'stubbed-email-id'
};
});
// Prepare a post and email model
const emailModel = await createPublishedPostEmail();
// Launch a lot of email jobs in the hope to mimic a possible race condition
const promises = [];
for (let i = 0; i < 100; i++) {
promises.push(_sendEmailJob({emailId: emailModel.id, options: {}}));
}
await Promise.all(promises);
await emailModel.refresh();
assert.equal(emailModel.get('status'), 'submitted');
const batchCount = await emailModel.related('emailBatches').count('id');
assert.equal(batchCount, 1, 'Should only have created one batch');
});
it('Can handle a failed post email', async function () {
sinon.stub(_mailgunClient, 'getInstance').returns({});
sinon.stub(_mailgunClient, 'send').callsFake(async () => {
throw new Error('Failed to send');
});
// Prepare a post and email model
const emailModel = await createPublishedPostEmail();
// Launch email job
await _sendEmailJob({emailId: emailModel.id, options: {}});
await emailModel.refresh();
emailModel.get('status').should.eql('failed');
});
});
/**
* This is one full E2E test that tests if the tracked links are valid and are working.
* More detailed tests don't have to cover the whole flow. But this test is useful because it also tests if the member uuids are correctly added in every link
* + it tests if all the pieces glue together correctly
*/
describe('Link click tracking', function () {
let ghostServer;
before(async function () {
const agents = await agentProvider.getAgentsWithFrontend();
agent = agents.adminAgent;
frontendAgent = agents.frontendAgent;
ghostServer = agents.ghostServer;
await fixtureManager.init('newsletters', 'members:newsletters');
await agent.loginAsOwner();
_sendEmailJob = require('../../../core/server/services/mega/mega')._sendEmailJob;
_mailgunClient = require('../../../core/server/services/bulk-email')._mailgunClient;
});
after(async function () {
await ghostServer.stop();
});
it('Tracks all the links in an email', async function () {
const linkRedirectService = require('../../../core/server/services/link-redirection');
const linkRedirectRepository = linkRedirectService.linkRedirectRepository;
const linkTrackingService = require('../../../core/server/services/link-tracking');
const linkClickRepository = linkTrackingService.linkClickRepository;
sinon.stub(_mailgunClient, 'getInstance').returns({});
const sendStub = sinon.stub(_mailgunClient, 'send');
sendStub.callsFake(async () => {
return {
id: 'stubbed-email-id'
};
});
// Prepare a post and email model
const emailModel = await createPublishedPostEmail();
// Launch email job
await _sendEmailJob({emailId: emailModel.id, options: {}});
await emailModel.refresh();
emailModel.get('status').should.eql('submitted');
// Get email data (first argument to sendStub call)
const emailData = sendStub.args[0][0];
const recipientData = sendStub.args[0][1];
const replacements = sendStub.args[0][2];
const recipient = recipientData[Object.keys(recipientData)[0]];
// Do the actual replacements for the first member, so we don't have to worry about them anymore
replacements.forEach((replacement) => {
emailData[replacement.format] = emailData[replacement.format].replace(
replacement.regexp,
recipient[replacement.id]
);
// Also force Mailgun format
emailData[replacement.format] = emailData[replacement.format].replace(
new RegExp(`%recipient.${replacement.id}%`, 'g'),
recipient[replacement.id]
);
});
const html = emailData.html;
const memberUuid = recipient.unique_id;
// Test if all links are replaced and contain the member id
const cheerio = require('cheerio');
const $ = cheerio.load(html);
const exclude = '%recipient.unsubscribe_url%';
let firstLink;
for (const el of $('a').toArray()) {
const href = $(el).attr('href');
if (href === exclude) {
continue;
}
// Check if the link is a tracked link
assert(href.includes('?m=' + memberUuid), href + ' is not tracked');
// Check if this link is also present in the plaintext version (with the right replacements)
assert(emailData.plaintext.includes(href), href + ' is not present in the plaintext version');
if (!firstLink) {
firstLink = new URL(href);
}
}
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
const link = links.find(l => l.from.pathname === firstLink.pathname);
assert(link, 'Link model not created');
for (const l of links) {
// Check ref added
assert.match(l.to.search, /ref=/);
}
// Mimic a click on a link
const path = firstLink.pathname + firstLink.search;
await frontendAgent.get(path)
.expect('Location', link.to.href)
.expect(302);
// Since this is all event based we should wait for all dispatched events to be completed.
await DomainEvents.allSettled();
// Check if click was tracked and associated with the correct member
const member = await models.Member.findOne({uuid: memberUuid});
const clickEvent = await linkClickRepository.getAll({member_id: member.id, link_id: link.link_id.toHexString()});
assert(clickEvent.length === 1, 'Click event was not tracked');
});
it('Does not add outbound refs if disabled', async function () {
mockManager.mockSetting('outbound_link_tagging', false);
const linkRedirectService = require('../../../core/server/services/link-redirection');
const linkRedirectRepository = linkRedirectService.linkRedirectRepository;
sinon.stub(_mailgunClient, 'getInstance').returns({});
const sendStub = sinon.stub(_mailgunClient, 'send');
sendStub.callsFake(async () => {
return {
id: 'stubbed-email-id'
};
});
// Prepare a post and email model
const emailModel = await createPublishedPostEmail();
// Launch email job
await _sendEmailJob({emailId: emailModel.id, options: {}});
await emailModel.refresh();
emailModel.get('status').should.eql('submitted');
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
for (const link of links) {
// Check ref is not added
assert.doesNotMatch(link.to.search, /ref=/);
}
});
// Remove this test once outboundLinkTagging goes GA
it('Does add outbound refs if disabled but flag is disabled', async function () {
mockManager.mockLabsDisabled('outboundLinkTagging');
mockManager.mockSetting('outbound_link_tagging', false);
const linkRedirectService = require('../../../core/server/services/link-redirection');
const linkRedirectRepository = linkRedirectService.linkRedirectRepository;
sinon.stub(_mailgunClient, 'getInstance').returns({});
const sendStub = sinon.stub(_mailgunClient, 'send');
sendStub.callsFake(async () => {
return {
id: 'stubbed-email-id'
};
});
// Prepare a post and email model
const emailModel = await createPublishedPostEmail();
// Launch email job
await _sendEmailJob({emailId: emailModel.id, options: {}});
await emailModel.refresh();
emailModel.get('status').should.eql('submitted');
const links = await linkRedirectRepository.getAll({filter: 'post_id:' + emailModel.get('post_id')});
for (const link of links) {
assert.match(link.to.search, /ref=/);
}
});
});
});

View File

@ -26,10 +26,6 @@ describe('Posts API', function () {
await localUtils.doAuth(request, 'users:extra', 'posts', 'emails', 'newsletters', 'members:newsletters');
});
beforeEach(function () {
mockManager.mockLabsDisabled('emailStability');
});
afterEach(function () {
mockManager.restore();
});

View File

@ -1,248 +0,0 @@
const should = require('should');
const sinon = require('sinon');
const errors = require('@tryghost/errors');
const labs = require('../../../../../core/shared/labs');
const {addEmail, _partitionMembersBySegment, _getEmailMemberRows, _transformEmailRecipientFilter, _getFromAddress, _getReplyToAddress} = require('../../../../../core/server/services/mega/mega');
const membersService = require('../../../../../core/server/services/members');
describe('MEGA', function () {
describe('addEmail', function () {
before(function () {
membersService.verificationTrigger = {
checkVerificationRequired: sinon.stub().resolves(false)
};
});
after(function () {
membersService.verificationTrigger = null;
});
afterEach(function () {
sinon.restore();
});
// via transformEmailRecipientFilter
it('throws when "none" is used as a email_recipient_filter', async function () {
const postModel = {
get: sinon.stub().returns('none'),
relations: {},
getLazyRelation: sinon.stub().returns({
get: sinon.stub().returns('active')
})
};
try {
await addEmail(postModel);
should.fail('addEmail did not throw');
} catch (err) {
should.equal(errors.utils.isGhostError(err), true);
err.message.should.equal('Cannot send email to "none" email_segment');
}
});
it('throws when sending to an archived newsletter', async function () {
const postModel = {
get: sinon.stub().returns('all'),
relations: {},
getLazyRelation: sinon.stub().returns({
get: sinon.stub().returns('archived')
})
};
try {
await addEmail(postModel);
should.fail('addEmail did not throw');
} catch (err) {
should.equal(errors.utils.isGhostError(err), true);
err.message.should.equal('Cannot send email to archived newsletters');
}
});
// via transformEmailRecipientFilter
it('throws when "public" is used as newsletter.visibility', async function () {
const newsletterGetter = sinon.stub();
newsletterGetter.withArgs('status').returns('active');
newsletterGetter.withArgs('visibility').returns('public');
const postModel = {
get: sinon.stub().returns('status:free'),
relations: {},
getLazyRelation: sinon.stub().returns({
get: newsletterGetter
})
};
sinon.stub(labs, 'isSet').returns(true);
try {
await addEmail(postModel);
should.fail('addEmail did not throw');
} catch (err) {
should.equal(errors.utils.isGhostError(err), true);
err.message.should.equal('Unexpected visibility value "public". Use one of the valid: "members", "paid".');
}
});
});
describe('transformEmailRecipientFilter', function () {
it('public newsletter', function () {
const newsletterGetter = sinon.stub();
newsletterGetter.withArgs('status').returns('active');
newsletterGetter.withArgs('visibility').returns('members');
const transformedFilter = _transformEmailRecipientFilter({id: 'test', get: newsletterGetter}, 'status:free,status:-free', 'field');
transformedFilter.should.equal('newsletters.id:test+(status:free,status:-free)');
});
it('paid-only newsletter', function () {
const newsletterGetter = sinon.stub();
newsletterGetter.withArgs('status').returns('active');
newsletterGetter.withArgs('visibility').returns('paid');
const transformedFilter = _transformEmailRecipientFilter({id: 'test', get: newsletterGetter}, 'status:free,status:-free', 'field');
transformedFilter.should.equal('newsletters.id:test+(status:free,status:-free)+status:-free');
});
});
describe('getEmailMemberRows', function () {
it('getEmailMemberRows throws when "none" is used as a recipient_filter', async function () {
const newsletterGetter = sinon.stub();
newsletterGetter.withArgs('status').returns('active');
newsletterGetter.withArgs('visibility').returns('members');
const emailModel = {
get: sinon.stub().returns('none'),
relations: {},
getLazyRelation: sinon.stub().returns({
id: 'test',
newsletterGetter
})
};
try {
await _getEmailMemberRows({emailModel});
should.fail('getEmailMemberRows did not throw');
} catch (err) {
should.equal(errors.utils.isGhostError(err), true);
err.message.should.equal('Cannot send email to "none" recipient_filter');
}
});
});
describe('partitionMembersBySegment', function () {
it('partition with no segments', function () {
const members = [{
name: 'Free Rish',
status: 'free'
}, {
name: 'Free Matt',
status: 'free'
}, {
name: 'Paid Daniel',
status: 'paid'
}];
const segments = [];
const partitions = _partitionMembersBySegment(members, segments);
partitions.unsegmented.length.should.equal(3);
partitions.unsegmented[0].name.should.equal('Free Rish');
});
it('partition members with single segment', function () {
const members = [{
name: 'Free Rish',
status: 'free'
}, {
name: 'Free Matt',
status: 'free'
}, {
name: 'Paid Daniel',
status: 'paid'
}];
const segments = ['status:free'];
const partitions = _partitionMembersBySegment(members, segments);
should.exist(partitions['status:free']);
partitions['status:free'].length.should.equal(2);
partitions['status:free'][0].name.should.equal('Free Rish');
partitions['status:free'][1].name.should.equal('Free Matt');
should.exist(partitions.unsegmented);
partitions.unsegmented.length.should.equal(1);
partitions.unsegmented[0].name.should.equal('Paid Daniel');
});
it('partition members with two segments', function () {
const members = [{
name: 'Free Rish',
status: 'free'
}, {
name: 'Free Matt',
status: 'free'
}, {
name: 'Paid Daniel',
status: 'paid'
}];
const segments = ['status:free', 'status:-free'];
const partitions = _partitionMembersBySegment(members, segments);
should.exist(partitions['status:free']);
partitions['status:free'].length.should.equal(2);
partitions['status:free'][0].name.should.equal('Free Rish');
partitions['status:free'][1].name.should.equal('Free Matt');
should.exist(partitions['status:-free']);
partitions['status:-free'].length.should.equal(1);
partitions['status:-free'][0].name.should.equal('Paid Daniel');
should.not.exist(partitions.unsegmented);
});
it('throws if unsupported segment has been used', function () {
const members = [];
const segments = ['not a valid segment'];
should.throws(() => {
_partitionMembersBySegment(members, segments);
}, errors.ValidationError);
});
});
describe('getFromAddress', function () {
it('Returns only the email when only fromAddress is specified', function () {
should(_getFromAddress('', 'test@example.com')).eql('test@example.com');
});
it('Adds a sender name when it\'s specified', function () {
should(_getFromAddress(' Unnamed sender!! ', 'test@example.com')).eql('" Unnamed sender!! "<test@example.com>');
});
it('Overwrites the fromAddress when the domain is localhost', function () {
should(_getFromAddress('Test', 'test@localhost')).eql('"Test"<localhost@example.com>');
});
it('Overwrites the fromAddress when the domain is ghost.local', function () {
should(_getFromAddress('123', '456@ghost.local')).eql('"123"<localhost@example.com>');
});
});
describe('getReplyToAddress', function () {
afterEach(function () {
sinon.restore();
});
it('Returns the from address by default', function () {
should(_getReplyToAddress('test@example.com')).eql('test@example.com');
should(_getReplyToAddress('test2@example.com', 'invalid')).eql('test2@example.com');
should(_getReplyToAddress('test3@example.com', 'newsletter')).eql('test3@example.com');
});
it('Returns the support email when the option is set to "support"', function () {
sinon.stub(membersService.config, 'getEmailSupportAddress').returns('support@example.com');
should(_getReplyToAddress('test4@example.com', 'support')).eql('support@example.com');
});
});
});

View File

@ -1,969 +0,0 @@
const assert = require('assert');
const sinon = require('sinon');
const settingsCache = require('../../../../../core/shared/settings-cache');
const models = require('../../../../../core/server/models');
const urlUtils = require('../../../../../core/shared/url-utils');
const urlService = require('../../../../../core/server/services/url');
const labs = require('../../../../../core/shared/labs');
const {parseReplacements, renderEmailForSegment, serialize, _getTemplateSettings, createUnsubscribeUrl, createPostSignupUrl, _PostEmailSerializer} = require('../../../../../core/server/services/mega/post-email-serializer');
const {HtmlValidate} = require('html-validate');
const audienceFeedback = require('../../../../../core/server/services/audience-feedback');
const logging = require('@tryghost/logging');
function assertKeys(object, keys) {
assert.deepStrictEqual(Object.keys(object).sort(), keys.sort());
}
describe('Post Email Serializer', function () {
afterEach(function () {
sinon.restore();
});
it('creates replacement pattern for valid format and value', function () {
const html = '<html>Hey %%{first_name}%%, what is up?</html>';
const plaintext = 'Hey %%{first_name}%%, what is up?';
const replaced = parseReplacements({
html,
plaintext
});
assert.equal(replaced.length, 2);
assert.equal(replaced[0].format, 'html');
assert.equal(replaced[0].recipientProperty, 'member_first_name');
assert.equal(replaced[1].format, 'plaintext');
assert.equal(replaced[1].recipientProperty, 'member_first_name');
});
it('reuses the same replacement pattern when used multiple times', function () {
const html = '<html>Hey %%{first_name}%%, what is up? Just repeating %%{first_name}%%</html>';
const plaintext = 'Hey %%{first_name}%%, what is up? Just repeating %%{first_name}%%';
const replaced = parseReplacements({
html,
plaintext
});
assert.equal(replaced.length, 2);
assert.equal(replaced[0].format, 'html');
assert.equal(replaced[0].recipientProperty, 'member_first_name');
assert.equal(replaced[1].format, 'plaintext');
assert.equal(replaced[1].recipientProperty, 'member_first_name');
});
it('creates multiple replacement pattern for valid format and value', function () {
const html = '<html>Hey %%{first_name}%%, %%{uuid}%% %%{first_name}%% %%{uuid}%%</html>';
const plaintext = 'Hey %%{first_name}%%, %%{uuid}%% %%{first_name}%% %%{uuid}%%';
const replaced = parseReplacements({
html,
plaintext
});
assert.equal(replaced.length, 4);
assert.equal(replaced[0].format, 'html');
assert.equal(replaced[0].recipientProperty, 'member_first_name');
assert.equal(replaced[1].format, 'html');
assert.equal(replaced[1].recipientProperty, 'member_uuid');
assert.equal(replaced[2].format, 'plaintext');
assert.equal(replaced[2].recipientProperty, 'member_first_name');
assert.equal(replaced[3].format, 'plaintext');
assert.equal(replaced[3].recipientProperty, 'member_uuid');
});
it('does not create replacements for unsupported variable names', function () {
const html = '<html>Hey %%{last_name}%%, what is up?</html>';
const plaintext = 'Hey %%{age}%%, what is up?';
const replaced = parseReplacements({
html,
plaintext
});
assert.equal(replaced.length, 0);
});
describe('serialize', function () {
afterEach(function () {
sinon.restore();
});
beforeEach(function () {
// Stub not working because service is undefined
audienceFeedback.service = {
buildLink: (uuid, postId, score) => {
const url = new URL('https://feedback.com');
url.hash = `#/feedback/${postId}/${score}/?uuid=${encodeURIComponent(uuid)}`;
return url;
}
};
});
it('should output valid HTML and escape HTML characters in mobiledoc', async function () {
const loggingStub = sinon.stub(logging, 'error');
sinon.stub(_PostEmailSerializer, 'serializePostModel').callsFake(async () => {
return {
// This is not realistic, but just to test escaping
url: 'https://testpost.com/t&es<3t-post"</body>/',
title: 'This is\' a blog po"st test <3</body>',
excerpt: 'This is a blog post test <3</body>',
authors: 'This is a blog post test <3</body>',
feature_image_alt: 'This is a blog post test <3</body>',
feature_image_caption: 'This is escaped in the frontend',
// This is a markdown post with all cards that contain <3 in all fields + </body> tags
// Note that some fields are already escaped in the frontend
// eslint-disable-next-line
mobiledoc: JSON.stringify({"version":"0.3.1","atoms":[],"cards":[['markdown',{markdown: 'This is a test markdown <3'}],['email',{html: '<p>Hey {first_name, "there"}, &lt;3</p>'}],['button',{alignment: 'center',buttonText: 'Button <3 </body>',buttonUrl: 'I <3 test </body>'}],['embed',{url: 'https://opensea.io/assets/0x495f947276749ce646f68ac8c248420045cb7b5e/85405838485527185183935784047901288096962687962314908211909792283039451054081/',type: 'nft',metadata: {version: '1.0',title: '<3 LOVE PENGUIN #1',author_name: 'Yeex',author_url: 'https://opensea.io/Yeex',provider_name: 'OpenSea',provider_url: 'https://opensea.io',image_url: 'https://lh3.googleusercontent.com/d1N3L-OGHpCptdTHMJxqBJtIfZFAJ-CSv0ZDwsaQTtPqy7NHCt_GVmnQoWt0S8Pfug4EmQr4UdPjrYSjop1KTKJfLt6DWmjnXdLdrQ',creator_name: 'Yeex<3',description: '<3 LOVE PENGUIN #1',collection_name: '<3 LOVE PENGUIN'},caption: 'I &lt;3 NFT captions'}],['callout',{calloutEmoji: '💡',calloutText: 'Callout test &lt;3',backgroundColor: 'grey'}],['toggle',{heading: 'Toggle &lt;3 header',content: '<p>Toggle &lt;3 content</p>'}],['video',{loop: false,src: '__GHOST_URL__/content/media/2022/09/20220"829-<3ghost</body>.mp4',fileName: '20220829 ghos"t.mp4',width: 3072,height: 1920,duration: 221.5,mimeType: 'video/mp4',thumbnailSrc: '__GHOST_URL__/content/images/2022/09/media-th\'umbn"ail-<3</body>.jpg',thumbnailWidth: 3072,thumbnailHeight: 1920,caption: 'Test &lt;3'}],['file',{loop: false,src: '__GHOST_URL__/content/files/2022/09/image<3</body>.png',fileName: 'image<3</body>.png',fileTitle: 'Image 1<3</body>',fileCaption: '<3</body>',fileSize: 152594}],['audio',{loop: false,src: '__GHOST_URL__/content/media/2022/09/sound<3</body>.mp3',title: 'I <3</body> audio files',duration: 27.252,mimeType: 'audio/mpeg'}],['file',{loop: false,src: '__GHOST_URL__/content/files/2022/09/image<3</body>.png',fileName: 'image<3</body>.png',fileTitle: 'I <3</body> file names',fileCaption: 'I <3</body> file descriptions',fileSize: 152594}],['embed',{caption: 'I &lt;3 YouTube videos Lost On You',html: '<iframe width="200" height="113" src="https://www.youtube.com/embed/wDjeBNv6ip0?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen title="LP - Lost On You (Live)"></iframe>',metadata: {author_name: 'LP',author_url: 'https://www.youtube.com/c/LP',height: 113,provider_name: 'YouTube',provider_url: 'https://www.youtube.com/',thumbnail_height: 360,thumbnail_url: 'https://i.ytimg.com/vi/wDjeBNv6ip0/hqdefault.jpg',thumbnail_width: 480,title: 'LP - Lost On You <3 (Live)',version: '1.0',width: 200},type: 'video',url: 'https://www.youtube.com/watch?v=wDjeBNv6ip0&list=RDwDjeBNv6ip0&start_radio=1'}],['image',{src: '__GHOST_URL__/content/images/2022/09/"<3</body>.png',width: 780,height: 744,caption: 'i &lt;3 images',alt: 'I <3</body> image alts'}],['gallery',{images: [{fileName: 'image<3</body>.png',row: 0,width: 780,height: 744,src: '__GHOST_URL__/content/images/2022/09/<3</body>.png'}],caption: 'I &lt;3 image galleries'}],['hr',{}]],markups: [['a',['href','https://google.com/<3</body>']],['strong'],['em']],sections: [[1,'p',[[0,[],0,'This is a <3</body> post test']]],[10,0],[10,1],[10,2],[10,3],[10,4],[10,5],[10,6],[10,7],[10,8],[10,9],[10,10],[10,11],[10,12],[1,'p',[[0,[0],1,'https://google.com/<3</body>']]],[1,'p',[[0,[],0,'Paragraph test <3</body>']]],[1,'p',[[0,[1],1,'Bold paragraph test <3</body>']]],[1,'h3',[[0,[],0,'Heading test <3</body>']]],[1,'blockquote',[[0,[],0,'Quote test <3</body>']]],[1,'p',[[0,[2],1,'Italic test<3</body>']]],[1,'p',[]]],ghostVersion: '4.0'})
};
});
const customSettings = {
icon: 'icon2<3</body>',
accent_color: '#000099',
timezone: 'UTC'
};
const settingsMock = sinon.stub(settingsCache, 'get');
settingsMock.callsFake((key, options) => {
if (customSettings[key]) {
return customSettings[key];
}
return settingsMock.wrappedMethod.call(settingsCache, key, options);
});
const template = {
name: 'My newsletter <3</body>',
header_image: 'https://testpost.com/test-post</body>/',
show_header_icon: true,
show_header_title: true,
show_feature_image: true,
title_font_category: 'sans-serif',
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
show_header_name: true,
// Note: we don't need to check the footer content because this should contain valid HTML (not text)
footer_content: '<span>Footer content with valid HTML</span>'
};
const newsletterMock = {
get: function (key) {
return template[key];
},
toJSON: function () {
return template;
}
};
const output = await serialize({}, newsletterMock, {isBrowserPreview: false});
loggingStub.calledOnce.should.be.true();
loggingStub.firstCall.firstArg.should.have.property('code').eql('IMAGE_SIZE_URL');
const htmlvalidate = new HtmlValidate({
extends: [
'html-validate:document',
'html-validate:standard'
],
rules: {
// We need deprecated attrs for legacy tables in older email clients
'no-deprecated-attr': 'off',
// Don't care that the first <hx> isn't <h1>
'heading-level': 'off'
},
elements: [
'html5',
// By default, html-validate requires the 'lang' attribute on the <html> tag. We don't really want that for now.
{
html: {
attributes: {
lang: {
required: false
}
}
}
}
]
});
const report = htmlvalidate.validateString(output.html);
// Improve debugging and show a snippet of the invalid HTML instead of just the line number or a huge HTML-dump
const parsedErrors = [];
if (!report.valid) {
const lines = output.html.split('\n');
const messages = report.results[0].messages;
for (const item of messages) {
if (item.severity !== 2) {
// Ignore warnings
continue;
}
const start = Math.max(item.line - 4, 0);
const end = Math.min(item.line + 4, lines.length - 1);
const html = lines.slice(start, end).map(l => l.trim()).join('\n');
parsedErrors.push(`${item.ruleId}: ${item.message}\n At line ${item.line}, col ${item.column}\n HTML-snippet:\n${html}`);
}
}
// Fail if invalid HTML
assert.equal(report.valid, true, 'Expected valid HTML without warnings, got errors:\n' + parsedErrors.join('\n\n'));
// Check footer content is not escaped
assert.equal(output.html.includes(template.footer_content), true);
// Check doesn't contain the non escaped string '<3'
assert.equal(output.html.includes('<3'), false);
// Check if the template is rendered fully to the end (to make sure we acutally test all these mobiledocs)
assert.equal(output.html.includes('Heading test &lt;3'), true);
});
it('output should already contain paywall when there is members-only content', async function () {
sinon.stub(_PostEmailSerializer, 'serializePostModel').callsFake(async () => {
return {
// This is not realistic, but just to test escaping
url: 'https://testpost.com/',
title: 'This is a test',
excerpt: 'This is a test',
authors: 'This is a test',
feature_image_alt: 'This is a test',
feature_image_caption: 'This is a test',
visibility: 'tiers',
// eslint-disable-next-line
mobiledoc: JSON.stringify({"version":"0.3.1","atoms":[],"cards":[["paywall",{}]],"markups":[],"sections":[[1,"p",[[0,[],0,"Free content"]]],[10,0],[1,"p",[[0,[],0,"Members only content"]]]],"ghostVersion":"4.0"})
};
});
const customSettings = {
accent_color: '#000099',
timezone: 'UTC'
};
const settingsMock = sinon.stub(settingsCache, 'get');
settingsMock.callsFake((key, options) => {
if (customSettings[key]) {
return customSettings[key];
}
return settingsMock.wrappedMethod.call(settingsCache, key, options);
});
const template = {
name: 'My newsletter',
header_image: '',
show_header_icon: true,
show_header_title: true,
show_feature_image: true,
title_font_category: 'sans-serif',
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
show_header_name: true,
// Note: we don't need to check the footer content because this should contain valid HTML (not text)
footer_content: '<span>Footer content with valid HTML</span>'
};
const newsletterMock = {
get: function (key) {
return template[key];
},
toJSON: function () {
return template;
}
};
const output = await serialize({}, newsletterMock, {isBrowserPreview: false});
assert(output.html.includes('<!--members-only-->'));
assert(output.html.includes('<!-- PAYWALL -->'));
assert(output.html.includes('<!-- POST CONTENT END -->'));
// Paywall content
assert(output.html.includes('Subscribe to'));
});
it('output should not contain paywall when there is members-only content but it is a free post', async function () {
sinon.stub(_PostEmailSerializer, 'serializePostModel').callsFake(async () => {
return {
// This is not realistic, but just to test escaping
url: 'https://testpost.com/',
title: 'This is a test',
excerpt: 'This is a test',
authors: 'This is a test',
feature_image_alt: 'This is a test',
feature_image_caption: 'This is a test',
visibility: 'members',
// eslint-disable-next-line
mobiledoc: JSON.stringify({"version":"0.3.1","atoms":[],"cards":[["paywall",{}]],"markups":[],"sections":[[1,"p",[[0,[],0,"Free content"]]],[10,0],[1,"p",[[0,[],0,"Members only content"]]]],"ghostVersion":"4.0"})
};
});
const customSettings = {
accent_color: '#000099',
timezone: 'UTC'
};
const settingsMock = sinon.stub(settingsCache, 'get');
settingsMock.callsFake((key, options) => {
if (customSettings[key]) {
return customSettings[key];
}
return settingsMock.wrappedMethod.call(settingsCache, key, options);
});
const template = {
name: 'My newsletter',
header_image: '',
show_header_icon: true,
show_header_title: true,
show_feature_image: true,
title_font_category: 'sans-serif',
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
show_header_name: true,
// Note: we don't need to check the footer content because this should contain valid HTML (not text)
footer_content: '<span>Footer content with valid HTML</span>'
};
const newsletterMock = {
get: function (key) {
return template[key];
},
toJSON: function () {
return template;
}
};
const output = await serialize({}, newsletterMock, {isBrowserPreview: false});
assert(output.html.includes('<!--members-only-->'));
assert(!output.html.includes('<!-- PAYWALL -->'));
assert(output.html.includes('<!-- POST CONTENT END -->'));
assert(!output.html.includes('Subscribe to'));
});
it('output should not contain paywall if there is no members-only-content', async function () {
sinon.stub(_PostEmailSerializer, 'serializePostModel').callsFake(async () => {
return {
// This is not realistic, but just to test escaping
url: 'https://testpost.com/',
title: 'This is a test',
excerpt: 'This is a test',
authors: 'This is a test',
feature_image_alt: 'This is a test',
feature_image_caption: 'This is a test',
// eslint-disable-next-line
mobiledoc: JSON.stringify({"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"Free content only"]]]],"ghostVersion":"4.0"})
};
});
const customSettings = {
accent_color: '#000099',
timezone: 'UTC'
};
const settingsMock = sinon.stub(settingsCache, 'get');
settingsMock.callsFake(function (key, options) {
if (customSettings[key]) {
return customSettings[key];
}
return settingsMock.wrappedMethod.call(settingsCache, key, options);
});
const template = {
name: 'My newsletter',
header_image: '',
show_header_icon: true,
show_header_title: true,
show_feature_image: true,
title_font_category: 'sans-serif',
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
show_header_name: true,
// Note: we don't need to check the footer content because this should contain valid HTML (not text)
footer_content: '<span>Footer content with valid HTML</span>'
};
const newsletterMock = {
get: function (key) {
return template[key];
},
toJSON: function () {
return template;
}
};
const output = await serialize({}, newsletterMock, {isBrowserPreview: false});
assert(output.html.includes('<!-- POST CONTENT END -->'));
assert(!output.html.includes('<!--members-only-->'));
assert(!output.html.includes('<!-- PAYWALL -->'));
});
it('should hide feedback buttons and ignore feedback_enabled if alpha flag disabled', async function () {
sinon.stub(labs, 'isSet').returns(false);
sinon.stub(_PostEmailSerializer, 'serializePostModel').callsFake(async () => {
return {
url: 'https://testpost.com/',
title: 'This is a test',
excerpt: 'This is a test',
authors: 'This is a test',
feature_image_alt: 'This is a test',
feature_image_caption: 'This is a test',
// eslint-disable-next-line
mobiledoc: JSON.stringify({"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"Free content only"]]]],"ghostVersion":"4.0"})
};
});
const customSettings = {
accent_color: '#000099',
timezone: 'UTC'
};
const settingsMock = sinon.stub(settingsCache, 'get');
settingsMock.callsFake(function (key, options) {
if (customSettings[key]) {
return customSettings[key];
}
return settingsMock.wrappedMethod.call(settingsCache, key, options);
});
const template = {
name: 'My newsletter',
header_image: '',
show_header_icon: true,
show_header_title: true,
show_feature_image: true,
title_font_category: 'sans-serif',
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
show_header_name: true,
feedback_enabled: true,
footer_content: 'footer'
};
const newsletterMock = {
get: function (key) {
return template[key];
},
toJSON: function () {
return template;
}
};
const output = await serialize({}, newsletterMock, {isBrowserPreview: false});
assert(!output.html.includes('%{feedback_button_like}%'));
assert(!output.html.includes('%{feedback_button_dislike}%'));
template.feedback_enabled = true;
const outputWithButtons = await serialize({}, newsletterMock, {isBrowserPreview: false});
assert(!outputWithButtons.html.includes('%{feedback_button_like}%'));
assert(!outputWithButtons.html.includes('%{feedback_button_dislike}%'));
});
/*it('should hide/show feedback buttons depending on feedback_enabled flag', async function () {
sinon.stub(labs, 'isSet').returns(true);
sinon.stub(_PostEmailSerializer, 'serializePostModel').callsFake(async () => {
return {
url: 'https://testpost.com/',
title: 'This is a test',
excerpt: 'This is a test',
authors: 'This is a test',
feature_image_alt: 'This is a test',
feature_image_caption: 'This is a test',
// eslint-disable-next-line
mobiledoc: JSON.stringify({"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"Free content only"]]]],"ghostVersion":"4.0"})
};
});
const customSettings = {
accent_color: '#000099',
timezone: 'UTC'
};
const settingsMock = sinon.stub(settingsCache, 'get');
settingsMock.callsFake(function (key, options) {
if (customSettings[key]) {
return customSettings[key];
}
return settingsMock.wrappedMethod.call(settingsCache, key, options);
});
const template = {
name: 'My newsletter',
header_image: '',
show_header_icon: true,
show_header_title: true,
show_feature_image: true,
title_font_category: 'sans-serif',
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
show_header_name: true,
feedback_enabled: false,
footer_content: 'footer'
};
const newsletterMock = {
get: function (key) {
return template[key];
},
toJSON: function () {
return template;
}
};
const output = await serialize({}, newsletterMock, {isBrowserPreview: false});
assert(!output.html.includes('%{feedback_button_like}%'));
assert(!output.html.includes('%{feedback_button_dislike}%'));
template.feedback_enabled = true;
const outputWithButtons = await serialize({}, newsletterMock, {isBrowserPreview: false});
assert(outputWithButtons.html.includes('%{feedback_button_like}%'));
assert(outputWithButtons.html.includes('%{feedback_button_dislike}%'));
});*/
it('handles lexical posts', async function () {
sinon.stub(_PostEmailSerializer, 'serializePostModel').callsFake(async () => {
return {
url: 'https://testpost.com/',
title: 'This is a lexical test',
excerpt: 'This is a lexical test',
authors: 'Mr. Test',
lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Bacon ipsum dolor amet porchetta drumstick swine ribeye, tail leberkas beef short loin fatback turducken salami pastrami ball tip shankle ground round. Jowl shankle bacon, short ribs cow ham pork loin meatloaf beef chislic tenderloin.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"altText":"","caption":"🤤","src":"http://localhost:2368/content/images/2022/11/michelle-shelly-captures-it-TJzhTJ2U8Jo-unsplash.jpg","type":"image"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Spare ribs chicken fatback shoulder. Flank swine kielbasa alcatra, porchetta capicola pork loin corned beef short ribs fatback.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1},{"altText":"","caption":"","src":"http://localhost:2368/content/images/2022/11/towfiqu-barbhuiya-yPYOG4_j6YI-unsplash-1.jpg","type":"image"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Prosciutto drumstick porchetta biltong leberkas tri-tip short ribs sausage picanha ham hock. Turducken buffalo venison hamburger landjaeger. Hamburger burgdoggen meatloaf pork belly picanha drumstick salami short ribs ham hock pork loin biltong chicken.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'
};
});
const customSettings = {
accent_color: '#000099',
timezone: 'UTC'
};
const settingsMock = sinon.stub(settingsCache, 'get');
settingsMock.callsFake(function (key, options) {
if (customSettings[key]) {
return customSettings[key];
}
return settingsMock.wrappedMethod.call(settingsCache, key, options);
});
const template = {
name: 'My newsletter',
header_image: '',
show_header_icon: true,
show_header_title: true,
show_feature_image: true,
title_font_category: 'sans-serif',
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
show_header_name: true,
// Note: we don't need to check the footer content because this should contain valid HTML (not text)
footer_content: '<span>Footer content with valid HTML</span>'
};
const newsletterMock = {
get: function (key) {
return template[key];
},
toJSON: function () {
return template;
}
};
const output = await serialize({}, newsletterMock, {isBrowserPreview: false});
assert(output.html.includes('Bacon ipsum dolor amet'));
assert(output.html.includes('michelle-shelly-captures-it-TJzhTJ2U8Jo-unsplash.jpg'));
});
});
describe('renderEmailForSegment', function () {
afterEach(function () {
sinon.restore();
});
it('shouldn\'t change an email that has no member segment', function () {
const email = {
otherProperty: true,
html: '<div>test</div>',
plaintext: 'test'
};
let output = renderEmailForSegment(email, 'status:free');
assertKeys(output, ['html', 'plaintext', 'otherProperty']);
assert.equal(output.html, '<div>test</div>');
assert.equal(output.plaintext, 'test');
assert.equal(output.otherProperty, true); // Make sure to keep other properties
});
it('should hide non matching member segments', function () {
const email = {
otherProperty: true,
html: 'hello<div data-gh-segment="status:free"> free users!</div><div data-gh-segment="status:-free"> paid users!</div>',
plaintext: 'test'
};
Object.freeze(email); // Make sure we don't modify `email`
let output = renderEmailForSegment(email, 'status:free');
assertKeys(output, ['html', 'plaintext', 'otherProperty']);
assert.equal(output.html, 'hello<div> free users!</div>');
assert.equal(output.plaintext, 'hello free users!');
output = renderEmailForSegment(email, 'status:-free');
assertKeys(output, ['html', 'plaintext', 'otherProperty']);
assert.equal(output.html, 'hello<div> paid users!</div>');
assert.equal(output.plaintext, 'hello paid users!');
});
it('should hide all segments when the segment filter is empty', function () {
const email = {
otherProperty: true,
html: 'hello<div data-gh-segment="status:free"> free users!</div><div data-gh-segment="status:-free"> paid users!</div>',
plaintext: 'test'
};
let output = renderEmailForSegment(email, null);
assert.equal(output.html, 'hello');
assert.equal(output.plaintext, 'hello');
});
it('should show paywall and hide members-only content for free members on paid posts', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'paid'
},
html: '<body><p>Free content</p><!--members-only--><p>Members content</p><!-- PAYWALL --><h2>Paywall</h2><!-- POST CONTENT END --></body>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:free');
assert.equal(output.html, `<body><p>Free content</p><!-- PAYWALL --><h2>Paywall</h2><!-- POST CONTENT END --></body>`);
assert.equal(output.plaintext, `Free content\n\n\nPaywall`);
});
it('should show paywall and hide members-only content for free members on paid posts (without <!-- POST CONTENT END -->)', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'paid'
},
html: '<p>Free content</p><!--members-only--><p>Members content</p><!-- PAYWALL --><h2>Paywall</h2>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:free');
assert.equal(output.html, `<p>Free content</p><!-- PAYWALL --><h2>Paywall</h2>`);
assert.equal(output.plaintext, `Free content\n\n\nPaywall`);
});
it('should hide members-only content for free members on paid posts (without <!-- PAYWALL -->)', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'paid'
},
html: '<body><p>Free content</p><!--members-only--><p>Members content</p><!-- POST CONTENT END --></body>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:free');
assert.equal(output.html, `<body><p>Free content</p><!-- POST CONTENT END --></body>`);
assert.equal(output.plaintext, `Free content`);
});
it('should hide members-only content for free members on paid posts (without <!-- PAYWALL --> and <!-- POST CONTENT END -->)', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'paid'
},
html: '<p>Free content</p><!--members-only--><p>Members content</p>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:free');
assert.equal(output.html, `<p>Free content</p>`);
assert.equal(output.plaintext, `Free content`);
});
it('should not modify HTML when there are no HTML comments', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'paid'
},
html: '<body><p>Free content</p></body>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:free');
assert.equal(output.html, `<body><p>Free content</p></body>`);
assert.equal(output.plaintext, `Free content`);
});
it('should hide paywall when <!-- POST CONTENT END --> is missing (paid members)', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'paid'
},
html: '<p>Free content</p><!-- PAYWALL --><h2>Paywall</h2>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:-free');
assert.equal(output.html, `<p>Free content</p>`);
assert.equal(output.plaintext, `Free content`);
});
it('should show members-only content for paid members on paid posts', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'paid'
},
html: '<body><p>Free content</p><!--members-only--><p>Members content</p><!-- PAYWALL --><h2>Paywall</h2><!-- POST CONTENT END --></body>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:-free');
assert.equal(output.html, `<body><p>Free content</p><!--members-only--><p>Members content</p><!-- POST CONTENT END --></body>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
it('should show members-only content for unknown members on paid posts', function () {
// Test if the default behaviour is to hide any paywalls and show the members-only content
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'paid'
},
html: '<body><p>Free content</p><!--members-only--><p>Members content</p><!-- PAYWALL --><h2>Paywall</h2><!-- POST CONTENT END --></body>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, null);
assert.equal(output.html, `<body><p>Free content</p><!--members-only--><p>Members content</p><!-- POST CONTENT END --></body>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
it('should show paywall content for free members on specific tier posts', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'tiers'
},
html: '<body><p>Free content</p><!--members-only--><p>Members content</p><!-- PAYWALL --><h2>Paywall</h2><!-- POST CONTENT END --></body>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:free');
assert.equal(output.html, `<body><p>Free content</p><!-- PAYWALL --><h2>Paywall</h2><!-- POST CONTENT END --></body>`);
assert.equal(output.plaintext, `Free content\n\n\nPaywall`);
});
it('should show members-only content for paid members on specific tier posts', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'paid'
},
html: '<p>Free content</p><!--members-only--><p>Members content</p>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:-free');
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
it('should show full content for free members on free posts', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'public'
},
html: '<p>Free content</p><!--members-only--><p>Members content</p>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:free');
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
it('should show full content for paid members on free posts', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
post: {
status: 'published',
visibility: 'public'
},
html: '<p>Free content</p><!--members-only--><p>Members content</p>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:-free');
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
it('should not crash on missing post for email with paywall', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
sinon.stub(labs, 'isSet').returns(true);
const email = {
html: '<p>Free content</p><!--members-only--><p>Members content</p>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:-free');
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
});
describe('createUnsubscribeUrl', function () {
before(function () {
models.init();
});
afterEach(function () {
sinon.restore();
});
it('generates unsubscribe url for preview', function () {
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
const unsubscribeUrl = createUnsubscribeUrl(null);
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?preview=1');
});
it('generates unsubscribe url with only member uuid', function () {
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
const unsubscribeUrl = createUnsubscribeUrl('member-abcd');
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?uuid=member-abcd');
});
it('generates unsubscribe url with both post and newsletter uuid', function () {
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
const unsubscribeUrl = createUnsubscribeUrl('member-abcd', {newsletterUuid: 'newsletter-abcd'});
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?uuid=member-abcd&newsletter=newsletter-abcd');
});
it('generates unsubscribe url with comments', function () {
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
const unsubscribeUrl = createUnsubscribeUrl('member-abcd', {comments: true});
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?uuid=member-abcd&comments=1');
});
});
describe('createPostSignupUrl', function () {
before(function () {
models.init();
});
afterEach(function () {
sinon.restore();
});
it('generates signup url on post for published post', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/blah/');
const unsubscribeUrl = createPostSignupUrl({
status: 'published',
id: 'abc123'
});
assert.equal(unsubscribeUrl, 'https://site.com/blah/#/portal/signup');
});
it('generates signup url on homepage for email only post', function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('https://site.com/test/404/');
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/test/');
const unsubscribeUrl = createPostSignupUrl({
status: 'sent',
id: 'abc123'
});
assert.equal(unsubscribeUrl, 'https://site.com/test/#/portal/signup');
});
});
describe('getTemplateSettings', function () {
before(function () {
models.init();
});
afterEach(function () {
sinon.restore();
});
it('uses the newsletter settings', async function () {
sinon.stub(settingsCache, 'get').callsFake(function (key) {
return {
icon: 'icon2',
accent_color: '#000099'
}[key];
});
const newsletterMock = {
get: function (key) {
return {
header_image: '',
show_header_icon: true,
show_header_title: true,
show_feature_image: true,
title_font_category: 'sans-serif',
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
feedback_enabled: false,
footer_content: 'footer',
show_header_name: true
}[key];
}
};
const res = await _getTemplateSettings(newsletterMock);
assert.deepStrictEqual(res, {
headerImage: '',
showHeaderIcon: 'icon2',
showHeaderTitle: true,
showFeatureImage: true,
titleFontCategory: 'sans-serif',
titleAlignment: 'center',
bodyFontCategory: 'serif',
showBadge: true,
feedbackEnabled: false,
footerContent: 'footer',
accentColor: '#000099',
adjustedAccentColor: '#000099',
adjustedAccentContrastColor: '#FFFFFF',
showHeaderName: true
});
});
});
});

View File

@ -1,85 +0,0 @@
const should = require('should');
const sinon = require('sinon');
const labs = require('../../../../../core/shared/labs');
const {getSegmentsFromHtml} = require('../../../../../core/server/services/mega/segment-parser');
describe('MEGA: Segment Parser', function () {
afterEach(function () {
sinon.restore();
});
it('extracts a single segments used in HTML', function () {
const html = '<div data-gh-segment="status:-free"><p>Plain html with no replacements</p></div>';
const segments = getSegmentsFromHtml(html);
segments.length.should.equal(1);
segments[0].should.equal('status:-free');
});
it('extracts multiple segments used in HTML', function () {
const html = `
<div data-gh-segment="status:-free"><p>Text for paid</p></div>
<div data-gh-segment="status:free"><p>Text for free</p></div>
<div data-gh-segment="status:-free,label.slug:VIP"><p>Text for paid VIP</p></div>
`;
const segments = getSegmentsFromHtml(html);
segments.length.should.equal(3);
segments[0].should.equal('status:-free');
segments[1].should.equal('status:free');
segments[2].should.equal('status:-free,label.slug:VIP');
});
it('extracts only unique segments', function () {
const html = `
<div data-gh-segment="status:-free"><p>Text for paid</p></div>
<div data-gh-segment="status:free"><p>Text for free</p></div>
<div data-gh-segment="status:-free"><p>Another message for paid member</p></div>
`;
const segments = getSegmentsFromHtml(html);
segments.length.should.equal(2);
segments[0].should.equal('status:-free');
segments[1].should.equal('status:free');
});
it('extracts all segments for paywalled content', function () {
sinon.stub(labs, 'isSet').returns(true);
const html = '<p>Free content</p><!--members-only--><p>Members content</p>';
const segments = getSegmentsFromHtml(html);
segments.length.should.equal(2);
segments[0].should.equal('status:free');
segments[1].should.equal('status:-free');
});
it('extracts all unique segments including paywalled content', function () {
sinon.stub(labs, 'isSet').returns(true);
const html = `
<div data-gh-segment="status:-free"><p>Text for paid</p></div>
<div data-gh-segment="status:free"><p>Text for free</p></div>
<div data-gh-segment="status:-free"><p>Another message for paid member</p></div>
<p>Free content</p><!--members-only--><p>Members content</p>
`;
const segments = getSegmentsFromHtml(html);
segments.length.should.equal(2);
segments[0].should.equal('status:-free');
segments[1].should.equal('status:free');
});
it('extracts no segments from HTML', function () {
const html = '<div data-gh-somethingelse="status:-free"><p>Plain html with no replacements</p></div>';
const segments = getSegmentsFromHtml(html);
segments.length.should.equal(0);
});
});

View File

@ -1,530 +0,0 @@
const should = require('should');
const sinon = require('sinon');
const cheerio = require('cheerio');
const render = require('../../../../../core/server/services/mega/template');
describe('Mega template', function () {
afterEach(function () {
sinon.restore();
});
it('Renders html correctly', function () {
const post = {
title: 'My post title',
excerpt: 'My post excerpt',
url: 'post url',
authors: 'post authors',
published_at: 'post published_at',
feature_image: 'post feature image',
feature_image_caption: 'post feature image caption',
feature_image_width: 'post feature image width',
feature_image_alt: 'post feature image alt',
html: '<div class="post-content-html"></div>'
};
const site = {
iconUrl: 'site icon url',
url: 'site url',
title: 'site title'
};
const templateSettings = {
headerImage: 'header image',
headerImageWidth: '600',
showHeaderIcon: true,
showHeaderTitle: true,
showHeaderName: true,
titleAlignment: 'left',
titleFontCategory: 'serif',
showFeatureImage: true,
bodyFontCategory: 'sans_serif',
footerContent: 'footer content',
showBadge: true
};
const newsletter = {
name: 'newsletter name'
};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('title').text()).eql(post.title);
should($('.preheader').text()).eql(post.excerpt);
should($('.header-image').length).eql(1);
const headerImage = $('.header-image img');
should(headerImage.length).eql(1);
should(headerImage.attr('src')).eql(templateSettings.headerImage);
should(headerImage.attr('width')).eql(templateSettings.headerImageWidth);
should($('td.site-info-bordered').length).eql(1);
should($('.site-info').length).eql(0);
should($('.site-url').length).eql(2);
should($('.site-icon').length).eql(1);
should($('.site-icon a').attr('href')).eql(site.url);
should($('.site-icon a img').attr('src')).eql(site.iconUrl);
should($('.site-icon a img').attr('alt')).eql(site.title);
should($('.site-title').length).eql(1);
const headerTitle = $($('.site-url').first());
should(headerTitle.length).eql(1);
should(headerTitle.hasClass('site-url-bottom-padding')).eql(false);
should(headerTitle.find('.site-title').attr('href')).eql(site.url);
should(headerTitle.find('.site-title').text()).eql(site.title);
const headerSubtitle = $($('.site-url').get()[1]);
should(headerSubtitle.length).eql(1);
should(headerSubtitle.hasClass('site-url-bottom-padding')).eql(true);
should(headerSubtitle.find('.site-subtitle').attr('href')).eql(site.url);
should(headerSubtitle.find('.site-subtitle').text()).eql(newsletter.name);
const postTitle = $('.post-title');
should(postTitle.length).eql(1);
should(postTitle.hasClass('post-title-serif')).eql(true);
should(postTitle.hasClass('post-title-left')).eql(true);
should($('.post-title a').attr('href')).eql(post.url);
should($('.post-title a').hasClass('post-title-link-left')).eql(true);
should($('.post-title a').text()).eql(post.title);
const postMeta = $('.post-meta');
should(postMeta.length).eql(1);
should(postMeta.hasClass('post-meta-left')).eql(true);
should(postMeta.text().trim().replace(/ *\n */g, '\n')).eql(`By ${post.authors} \n${post.published_at} \nView online →`);
should(postMeta.find('a').attr('href')).eql(post.url);
const featureImage = $('.feature-image');
should(featureImage.length).eql(1);
should(featureImage.hasClass('feature-image-with-caption')).eql(true);
should(featureImage.find('img').attr('src')).eql(post.feature_image);
should(featureImage.find('img').attr('width')).eql(post.feature_image_width);
should(featureImage.find('img').attr('alt')).eql(post.feature_image_alt);
const imageCaption = $('.feature-image-caption');
should(imageCaption.length).eql(1);
should(imageCaption.text()).eql(post.feature_image_caption);
should($('.post-content-sans-serif').length).eql(1);
should($('.post-content').length).eql(0);
should($('.post-content-html').length).eql(1);
const footers = $('.footer').get();
should(footers.length).eql(2);
should($(footers[0]).text()).eql(templateSettings.footerContent);
should($(footers[1]).text()).eql(`${site.title} © ${(new Date()).getFullYear()} Unsubscribe`);
should($(footers[1]).find('a').attr('href')).eql('%recipient.unsubscribe_url%');
const footerPowered = $('.footer-powered');
should(footerPowered.length).eql(1);
should(footerPowered.find('a img').attr('alt')).eql('Powered by Ghost');
});
it('Correctly escapes the contents', function () {
const post = {
title: 'I <3 Posts',
html: '<div class="post-content-html">I am &lt;100 years old</div>',
feature_image: 'https://example.com/image.jpg',
feature_image_alt: 'I <3 alt text',
feature_image_caption: 'I &lt;3 images' // escaped in frontend
};
const site = {
iconUrl: 'site icon url',
url: 'site url',
title: 'Egg <3 eggs'
};
const templateSettings = {
headerImage: 'header image',
headerImageWidth: '600',
showHeaderIcon: true,
showHeaderTitle: true,
showHeaderName: true,
titleAlignment: 'left',
titleFontCategory: 'serif',
showFeatureImage: true,
bodyFontCategory: 'sans_serif',
footerContent: 'footer content',
showBadge: true
};
const newsletter = {
name: '<100 eggs to go'
};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.site-title').text()).eql(site.title);
should($('.site-title').html()).eql('Egg &lt;3 eggs');
should($('.post-content-html').length).eql(1);
should($('.post-content-html').text()).eql('I am <100 years old');
should($('.post-content-html').html()).eql('I am &lt;100 years old');
should($('.feature-image').html()).containEql('"I &lt;3 alt text"');
should($('.feature-image-caption').html()).eql('I &lt;3 images');
should($('.site-subtitle').html()).eql('&lt;100 eggs to go');
});
it('Doesn\'t strip class or style attributes when escaping content', function () {
const post = {
title: 'I <3 Posts',
html: '<div class="post-content-html"><span class="custom" style="font-weight: 900; display: flex;">BOLD</span></div>'
};
const site = {
iconUrl: 'site icon url',
url: 'site url',
title: 'Egg <3 eggs'
};
const templateSettings = {
headerImage: 'header image',
headerImageWidth: '600',
showHeaderIcon: true,
showHeaderTitle: true,
showHeaderName: true,
titleAlignment: 'left',
titleFontCategory: 'serif',
showFeatureImage: true,
bodyFontCategory: 'sans_serif',
footerContent: 'footer content',
showBadge: true
};
const newsletter = {
name: '<100 eggs to go'
};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should(html).containEql('class="custom"');
// note that some part of rendering/sanitisation removes spaces from the style description
should(html).containEql('style="font-weight: 900; display: flex;"');
});
it('Uses the post title as a fallback for the excerpt', function () {
const post = {
title: 'My post title'
};
const site = {};
const templateSettings = {};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.preheader').text()).eql(post.title + ' ');
});
it('Hides the header image if it isn\'t set', function () {
const post = {};
const site = {};
const templateSettings = {
headerImage: ''
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.header-image').length).eql(0);
});
it('Shows no width in the header if headerImageWidth isn\'t defined', function () {
const post = {
title: 'My post title'
};
const site = {};
const templateSettings = {
headerImage: 'header image'
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.header-image').length).eql(1);
should($('.header-image img').length).eql(1);
should(typeof $('.header-image img').attr('width')).eql('undefined');
});
it('Shows no header when all header features are disabled', function () {
const post = {
title: 'My post title'
};
const site = {};
const templateSettings = {
showHeaderIcon: false,
showHeaderTitle: false,
showHeaderName: false
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.site-info-bordered').length).eql(0);
should($('.site-info').length).eql(0);
should($('.site-url').length).eql(0);
should($('.site-icon').length).eql(0);
should($('.site-title').length).eql(0);
should($('.site-subtitle').length).eql(0);
should($('.site-url-bottom-padding').length).eql(0);
});
it('Shows the right header for showHeaderIcon:true, showHeaderTitle:false, showHeaderName:false', function () {
/**
* The edge case where the iconUrl is falsy in the current configuration wasn't tested.
* The reason is that the Ghost admin is guarding against the edge case.
*/
const post = {};
const site = {
iconUrl: 'site icon url'
};
const templateSettings = {
showHeaderIcon: true,
showHeaderTitle: false,
showHeaderName: false
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.site-info-bordered').length).eql(0);
should($('.site-info').length).eql(1);
should($('.site-icon').length).eql(1);
should($('.site-url').length).eql(0);
should($('.site-title').length).eql(0);
should($('.site-subtitle').length).eql(0);
});
it('Shows the right header for showHeaderIcon:false, showHeaderTitle:true, showHeaderName:false', function () {
const post = {};
const site = {
title: 'site title'
};
const templateSettings = {
showHeaderIcon: false,
showHeaderTitle: true,
showHeaderName: false
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.site-info-bordered').length).eql(1);
should($('.site-info').length).eql(0);
should($('.site-icon').length).eql(0);
should($('.site-url').length).eql(1);
should($('.site-url').hasClass('site-url-bottom-padding')).eql(true);
should($('.site-url').text()).eql(site.title);
should($('.site-title').length).eql(1);
should($('.site-subtitle').length).eql(0);
});
it('Shows the right header for showHeaderIcon:false, showHeaderTitle:false, showHeaderName:true', function () {
const post = {};
const site = {};
const templateSettings = {
showHeaderIcon: false,
showHeaderTitle: false,
showHeaderName: true
};
const newsletter = {
name: 'newsletter name'
};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.site-info-bordered').length).eql(0);
should($('.site-info').length).eql(1);
should($('.site-icon').length).eql(0);
should($('.site-url').length).eql(1);
should($('.site-url').hasClass('site-url-bottom-padding')).eql(true);
should($('.site-url').text()).eql(newsletter.name);
should($('.site-title').length).eql(1);
should($('.site-subtitle').length).eql(0);
});
it('Shows the right header for showHeaderIcon:true, showHeaderTitle:true, showHeaderName:false', function () {
const post = {};
const site = {
iconUrl: 'site icon url',
title: 'site title'
};
const templateSettings = {
showHeaderIcon: true,
showHeaderTitle: true,
showHeaderName: false
};
const newsletter = {
};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.site-info-bordered').length).eql(1);
should($('.site-info').length).eql(0);
should($('.site-icon').length).eql(1);
should($('.site-url').length).eql(1);
should($('.site-url').hasClass('site-url-bottom-padding')).eql(true);
should($('.site-url').text()).eql(site.title);
should($('.site-title').length).eql(1);
should($('.site-subtitle').length).eql(0);
});
it('Shows the right header for showHeaderIcon:true, showHeaderTitle:false, showHeaderName:true', function () {
const post = {};
const site = {
iconUrl: 'site icon url'
};
const templateSettings = {
showHeaderIcon: true,
showHeaderTitle: false,
showHeaderName: true
};
const newsletter = {
name: 'newsletter name'
};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.site-info-bordered').length).eql(0);
should($('.site-info').length).eql(1);
should($('.site-icon').length).eql(1);
should($('.site-url').length).eql(1);
should($('.site-url').hasClass('site-url-bottom-padding')).eql(true);
should($('.site-url').text()).eql(newsletter.name);
should($('.site-title').length).eql(1);
should($('.site-subtitle').length).eql(0);
});
it('Shows the right html titleFontCategory isn\'t set to `serif` and when titleAlignment is set to `center`', function () {
const post = {};
const site = {};
const templateSettings = {
titleFontCategory: 'sans_serif',
titleAlignment: 'center'
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
const postTitle = $('.post-title');
should(postTitle.hasClass('post-title-serif')).eql(false);
should(postTitle.hasClass('post-title-left')).eql(false);
should($('.post-title a').hasClass('post-title-link-left')).eql(false);
should($('.post-meta').hasClass('post-meta-left')).eql(false);
});
it('Renders correctly without a feature image (showFeatureImage set to `false`)', function () {
const post = {
feature_image: 'post feature image'
};
const site = {};
const templateSettings = {
showFeatureImage: false
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.feature-image').length).eql(0);
should($('.feature-image-caption').length).eql(0);
});
it('Renders correctly without a feature image (post doesn\'t have a feature image)', function () {
const post = {};
const site = {};
const templateSettings = {
showFeatureImage: true
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.feature-image').length).eql(0);
should($('.feature-image-caption').length).eql(0);
});
it('Renders correctly a feature image without width nor alt', function () {
const post = {
feature_image: 'post feature image'
};
const site = {};
const templateSettings = {
showFeatureImage: true
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
const featureImage = $('.feature-image');
should(featureImage.length).eql(1);
should(featureImage.hasClass('feature-image-with-caption')).eql(false);
should(featureImage.find('img').attr('src')).eql(post.feature_image);
should(typeof featureImage.find('img').attr('width')).eql('undefined');
should(typeof featureImage.find('img').attr('alt')).eql('undefined');
});
it('Renders correctly without a feature image caption', function () {
const post = {
feature_image: 'post feature image'
};
const site = {};
const templateSettings = {
showFeatureImage: true
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
const featureImage = $('.feature-image');
should(featureImage.length).eql(1);
should(featureImage.hasClass('feature-image-with-caption')).eql(false);
const imageCaption = $('.feature-image-caption');
should(imageCaption.length).eql(0);
});
it('Shows no footer when `footerContent` is falsy', function () {
const post = {};
const site = {};
const templateSettings = {
footerContent: ''
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
const footer = $('.footer');
should(footer.length).eql(1);
should(footer.text()).eql(`${site.title} © ${(new Date()).getFullYear()} Unsubscribe`);
});
it('Shows no badge when `showBadge` is false', function () {
const post = {};
const site = {};
const templateSettings = {
showBadge: false
};
const newsletter = {};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.footer-powered').length).eql(0);
});
});

View File

@ -79,7 +79,7 @@ module.exports = class MentionSendingService {
async send({source, target, endpoint}) {
logging.info('[Webmention] Sending webmention from ' + source.href + ' to ' + target.href + ' via ' + endpoint.href);
// default content type is application/x-www-form-encoded which is what we need for the webmentions spec
const response = await this.#externalRequest.post(endpoint.href, {
form: {
@ -96,7 +96,7 @@ module.exports = class MentionSendingService {
if (response.statusCode >= 200 && response.statusCode < 300) {
return;
}
throw new errors.BadRequestError({
message: 'Webmention sending failed with status code ' + response.statusCode,
statusCode: response.statusCode