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

Render an email correctly according to the associated member segment

issue https://github.com/TryGhost/Team/issues/829
This commit is contained in:
Thibaut Patel 2021-07-01 13:36:36 +02:00
parent af4bfb8862
commit b94c8bcfd4
4 changed files with 88 additions and 22 deletions

View file

@ -91,11 +91,11 @@ module.exports = {
// 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');
.select('id', 'member_segment');
const batchResults = await Promise.map(batchIds, async ({id: emailBatchId}) => {
const batchResults = await Promise.map(batchIds, async ({id: emailBatchId, member_segment: memberSegment}) => {
try {
await this.processEmailBatch({emailBatchId, options});
await this.processEmailBatch({emailBatchId, options, memberSegment});
return new SuccessfulBatch(emailBatchId);
} catch (error) {
return new FailedBatch(emailBatchId, error);
@ -133,7 +133,7 @@ module.exports = {
},
// accepts an ID rather than an EmailBatch model to better support running via a job queue
async processEmailBatch({emailBatchId, options}) {
async processEmailBatch({emailBatchId, options, memberSegment}) {
const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
const emailBatchModel = await models.EmailBatch
@ -163,7 +163,7 @@ module.exports = {
try {
// send the email
const sendResponse = await this.send(emailBatchModel.relations.email.toJSON(), recipientRows);
const sendResponse = await this.send(emailBatchModel.relations.email.toJSON(), recipientRows, memberSegment);
// update batch success status
return await emailBatchModel.save({
@ -196,9 +196,10 @@ module.exports = {
/**
* @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 {Object} - {providerId: 'xxx'}
*/
send(emailData, recipients) {
send(emailData, recipients, memberSegment) {
const mailgunInstance = mailgunProvider.getInstance();
if (!mailgunInstance) {
return;
@ -229,6 +230,10 @@ module.exports = {
recipientData[recipient.member_email] = data;
});
if (memberSegment) {
emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment);
}
return mailgunProvider.send(emailData, recipientData, replacements).then((response) => {
debug(`sent message (${Date.now() - startTime}ms)`);
return response;

View file

@ -367,7 +367,7 @@ async function getEmailMemberRows({emailModel, memberSegment, options}) {
*/
async function createSegmentedEmailBatches({emailModel, options}) {
const segments = getSegmentsFromHtml(emailModel.get('html'));
const batchIds = [];
let batchIds = [];
if (segments.length) {
for (const memberSegment of segments) {
@ -376,11 +376,11 @@ async function createSegmentedEmailBatches({emailModel, options}) {
memberSegment,
options
});
batchIds.push(emailBatchIds);
batchIds = emailBatchIds;
}
} else {
const emailBatchIds = createEmailBatches({emailModel, options});
batchIds.push(emailBatchIds);
const emailBatchIds = await createEmailBatches({emailModel, options});
batchIds = emailBatchIds;
}
return batchIds;

View file

@ -22,6 +22,20 @@ const getSite = () => {
});
};
const htmlToPlaintext = (html) => {
// same options as used in Post model for generating plaintext but without `wordwrap: 80`
// to avoid replacement strings being split across lines and for mail clients to handle
// word wrapping based on user preferences
return htmlToText.fromString(html, {
wordwrap: false,
ignoreImage: true,
hideLinkHrefIfSameAsText: true,
preserveNewlines: true,
returnDomByDefault: true,
uppercaseHeadings: false
});
};
/**
* createUnsubscribeUrl
*
@ -195,17 +209,7 @@ const serialize = async (postModel, options = {isBrowserPreview: false, apiVersi
}
post.html = mobiledocLib.mobiledocHtmlRenderer.render(JSON.parse(post.mobiledoc), {target: 'email'});
// same options as used in Post model for generating plaintext but without `wordwrap: 80`
// to avoid replacement strings being split across lines and for mail clients to handle
// word wrapping based on user preferences
post.plaintext = htmlToText.fromString(post.html, {
wordwrap: false,
ignoreImage: true,
hideLinkHrefIfSameAsText: true,
preserveNewlines: true,
returnDomByDefault: true,
uppercaseHeadings: false
});
post.plaintext = htmlToPlaintext(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
@ -276,8 +280,27 @@ const serialize = async (postModel, options = {isBrowserPreview: false, apiVersi
};
};
function renderEmailForSegment(email, memberSegment) {
const result = {...email};
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 = $.html();
result.plaintext = htmlToPlaintext(result.html);
return result;
}
module.exports = {
serialize,
createUnsubscribeUrl,
renderEmailForSegment,
parseReplacements
};

View file

@ -1,6 +1,6 @@
const should = require('should');
const {parseReplacements} = require('../../../../core/server/services/mega/post-email-serializer');
const {parseReplacements, renderEmailForSegment} = require('../../../../core/server/services/mega/post-email-serializer');
describe('Post Email Serializer', function () {
it('creates replacement pattern for valid format and value', function () {
@ -31,4 +31,42 @@ describe('Post Email Serializer', function () {
replaced.length.should.equal(0);
});
describe('renderEmailForSegment', function () {
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');
output.should.have.keys('html', 'plaintext', 'otherProperty');
output.html.should.eql('<div>test</div>');
output.plaintext.should.eql('test');
output.otherProperty.should.eql(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');
output.should.have.keys('html', 'plaintext', 'otherProperty');
output.html.should.eql('hello<div> free users!</div>');
output.plaintext.should.eql('hello free users!');
output = renderEmailForSegment(email, 'status:-free');
output.should.have.keys('html', 'plaintext', 'otherProperty');
output.html.should.eql('hello<div> paid users!</div>');
output.plaintext.should.eql('hello paid users!');
});
});
});