Added the feedback buttons in the emails (#15619)

closes TryGhost/Team#2046
closes TryGhost/Team#2045
- Added feedback buttons markup.
- Added feedback links generation.
This commit is contained in:
Elena Baidakova 2022-10-14 18:12:17 +04:00 committed by GitHub
parent e074676e12
commit e831be6bc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 212 additions and 16 deletions

View File

@ -1,7 +1,27 @@
class AudienceFeedbackService {
buildLink() {
// todo
return new URL('https://example.com');
/** @type URL */
#baseURL;
/**
* @param {object} deps
* @param {object} deps.config
* @param {URL} deps.config.baseURL
*/
constructor(deps) {
this.#baseURL = deps.config.baseURL;
}
/**
* @param {string} uuid
* @param {string} postId
* @param {0 | 1} score
*/
buildLink(uuid, postId, score) {
const url = new URL(this.#baseURL);
url.searchParams.set('action', 'feedback');
url.searchParams.set('post', postId);
url.searchParams.set('uuid', uuid);
url.searchParams.set('score', `${score}`);
return url;
}
}

View File

@ -1,3 +1,4 @@
const urlUtils = require('../../../shared/url-utils');
const FeedbackRepository = require('./FeedbackRepository');
class AudienceFeedbackServiceWrapper {
@ -20,7 +21,11 @@ class AudienceFeedbackServiceWrapper {
});
// Expose the service
this.service = new AudienceFeedbackService();
this.service = new AudienceFeedbackService({
config: {
baseURL: new URL(urlUtils.urlFor('home', true))
}
});
this.controller = new AudienceFeedbackController({repository: this.repository});
}
}

View File

@ -11,6 +11,7 @@ const debug = require('@tryghost/debug')('mega');
const postEmailSerializer = require('../mega/post-email-serializer');
const configService = require('../../../shared/config');
const settingsCache = require('../../../shared/settings-cache');
const labs = require('../../../shared/labs');
const messages = {
error: 'The email service received an error from mailgun and was unable to send.'
@ -234,6 +235,11 @@ module.exports = {
unsubscribe_url: postEmailSerializer.createUnsubscribeUrl(recipient.member_uuid, {newsletterUuid})
};
if (labs.isSet('audienceFeedback')) {
// create unique urls for every recipient (for example, for feedback buttons)
emailData = postEmailSerializer.createUserLinks(emailData, recipient.member_uuid);
}
// computed properties on recipients - TODO: better way of handling these
recipient.member_first_name = (recipient.member_name || '').split(' ')[0];

View File

@ -0,0 +1,69 @@
const {Color} = require('@tryghost/color-utils');
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(templateStrings.like, positiveLink.href);
html = html.replace(templateStrings.dislike, negativeLink.href);
return html;
};
const getTemplate = (accentColor) => {
const likeButtonHtml = getButtonHtml(templateStrings.like, 'More like this', accentColor);
const dislikeButtonHtml = getButtonHtml(templateStrings.dislike, 'Less like this', accentColor);
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;">What did you think of 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, accentColor) {
const color = new Color(accentColor);
const bgColor = `${accentColor}10`;
const textColor = color.darken(0.6).hex();
return (`
<td dir="ltr" valign="top" align="center" style="font-family: inherit; font-size: 14px; text-align: center;" nowrap>
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="width: auto !important;">
<tr>
<td style="padding: 0 6px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';">
<a href=${href} style="background-color: ${bgColor}; color: ${textColor}; border-radius: 22px; font-family: inherit; padding: 12px 20px; border: none; font-size: 14px; font-weight: bold; line-height: 100%; text-decoration: none; display: block;">
${buttonText}
</a>
</td>
</tr>
</table>
</td>
`);
}
module.exports = {
generateLinks,
getTemplate
};

View File

@ -16,11 +16,12 @@ 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 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) {
@ -107,6 +108,23 @@ const PostEmailSerializer = {
return signupUrl.href;
},
/**
* createUserLinks
*
* Generate personalised links for each user
*
* @param {string} memberUuid member uuid
* @param {Object} email
*/
createUserLinks(email, memberUuid) {
const result = {...email};
result.html = feedbackButtons.generateLinks(result.post.id, memberUuid, result.html);
result.plaintext = htmlToPlaintext.email(result.html);
return result;
},
// 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 rather than html and plaintext so we can render email-specific contents
@ -206,6 +224,7 @@ const PostEmailSerializer = {
titleAlignment: newsletter.get('title_alignment'),
bodyFontCategory: newsletter.get('body_font_category'),
showBadge: newsletter.get('show_badge'),
feedbackEnabled: newsletter.get('feedback_enabled'),
footerContent: newsletter.get('footer_content'),
showHeaderName: newsletter.get('show_header_name'),
accentColor,
@ -335,7 +354,7 @@ const PostEmailSerializer = {
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
@ -369,7 +388,7 @@ const PostEmailSerializer = {
// 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;
@ -490,7 +509,7 @@ const PostEmailSerializer = {
});
result.html = this.formatHtmlForEmail($.html());
result.plaintext = htmlToPlaintext.email(result.html);
result.plaintext = htmlToPlaintext.email(result.html);
delete result.post;
return result;
@ -501,6 +520,7 @@ module.exports = {
serialize: PostEmailSerializer.serialize.bind(PostEmailSerializer),
createUnsubscribeUrl: PostEmailSerializer.createUnsubscribeUrl.bind(PostEmailSerializer),
createPostSignupUrl: PostEmailSerializer.createPostSignupUrl.bind(PostEmailSerializer),
createUserLinks: PostEmailSerializer.createUserLinks.bind(PostEmailSerializer),
renderEmailForSegment: PostEmailSerializer.renderEmailForSegment.bind(PostEmailSerializer),
parseReplacements: PostEmailSerializer.parseReplacements.bind(PostEmailSerializer),
// Export for tests

View File

@ -1,4 +1,5 @@
const {escapeHtml: escape} = require('@tryghost/string');
const feedbackButtons = require('./feedback-buttons');
/* eslint indent: warn, no-irregular-whitespace: warn */
const iff = (cond, yes, no) => (cond ? yes : no);
@ -1265,6 +1266,8 @@ ${ templateSettings.showBadge ? `
<!-- END MAIN CONTENT AREA -->
${iff(templateSettings.feedbackEnabled, feedbackButtons.getTemplate(templateSettings.accentColor), '')}
<tr>
<td class="wrapper" align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-top: 40px; padding-bottom: 30px;">

View File

@ -416,6 +416,8 @@ table.body figcaption a {
<!-- END MAIN CONTENT AREA -->
<tr>
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
@ -468,7 +470,7 @@ exports[`Email Preview API Read can read post email preview with email card and
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": "18188",
"content-length": "18216",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -806,6 +808,8 @@ table.body figcaption a {
<!-- END MAIN CONTENT AREA -->
<tr>
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
@ -870,7 +874,7 @@ exports[`Email Preview API Read can read post email preview with fields 2: [head
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": "23013",
"content-length": "23041",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -1234,6 +1238,8 @@ table.body figcaption a {
<!-- END MAIN CONTENT AREA -->
<tr>
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
@ -1280,7 +1286,7 @@ exports[`Email Preview API Read has custom content transformations for email com
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": "17950",
"content-length": "17978",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -1618,6 +1624,8 @@ table.body figcaption a {
<!-- END MAIN CONTENT AREA -->
<tr>
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
@ -1664,7 +1672,7 @@ exports[`Email Preview API Read uses the newsletter provided through ?newsletter
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": "18316",
"content-length": "18344",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -2388,6 +2396,8 @@ table.body figcaption a {
<!-- END MAIN CONTENT AREA -->
<tr>
<td class=\\"wrapper\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; padding-top: 40px; padding-bottom: 30px;\\">
@ -2434,7 +2444,7 @@ exports[`Email Preview API Read uses the posts newsletter by default 2: [headers
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": "18316",
"content-length": "18344",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",

View File

@ -7,7 +7,7 @@ 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');
function assertKeys(object, keys) {
assert.deepStrictEqual(Object.keys(object).sort(), keys.sort());
}
@ -16,7 +16,7 @@ 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?';
@ -137,7 +137,7 @@ describe('Post Email Serializer', function () {
// 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;
@ -344,6 +344,67 @@ describe('Post Email Serializer', function () {
assert(!output.html.includes('<!--members-only-->'));
assert(!output.html.includes('<!-- PAYWALL -->'));
});
it('should hide/show feedback buttons depending on feedback_enabled flag', async function () {
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}%'));
});
});
describe('renderEmailForSegment', function () {
@ -708,6 +769,7 @@ describe('Post Email Serializer', function () {
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
feedback_enabled: false,
footer_content: 'footer',
show_header_name: true
}[key];
@ -723,6 +785,7 @@ describe('Post Email Serializer', function () {
titleAlignment: 'center',
bodyFontCategory: 'serif',
showBadge: true,
feedbackEnabled: false,
footerContent: 'footer',
accentColor: '#000099',
adjustedAccentColor: '#000099',