From e831be6bc2785ea142ba28ce6a755a385be7d710 Mon Sep 17 00:00:00 2001 From: Elena Baidakova Date: Fri, 14 Oct 2022 18:12:17 +0400 Subject: [PATCH] Added the feedback buttons in the emails (#15619) closes TryGhost/Team#2046 closes TryGhost/Team#2045 - Added feedback buttons markup. - Added feedback links generation. --- .../lib/AudienceFeedbackService.js | 26 ++++++- .../services/audience-feedback/index.js | 7 +- .../bulk-email/bulk-email-processor.js | 6 ++ .../server/services/mega/feedback-buttons.js | 69 +++++++++++++++++++ .../services/mega/post-email-serializer.js | 28 ++++++-- .../core/server/services/mega/template.js | 3 + .../__snapshots__/email-previews.test.js.snap | 20 ++++-- .../mega/post-email-serializer.test.js | 69 ++++++++++++++++++- 8 files changed, 212 insertions(+), 16 deletions(-) create mode 100644 ghost/core/core/server/services/mega/feedback-buttons.js diff --git a/ghost/audience-feedback/lib/AudienceFeedbackService.js b/ghost/audience-feedback/lib/AudienceFeedbackService.js index 3836a31d45..fbd1da0f74 100644 --- a/ghost/audience-feedback/lib/AudienceFeedbackService.js +++ b/ghost/audience-feedback/lib/AudienceFeedbackService.js @@ -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; } } diff --git a/ghost/core/core/server/services/audience-feedback/index.js b/ghost/core/core/server/services/audience-feedback/index.js index a111db6e5a..0eda9c0c75 100644 --- a/ghost/core/core/server/services/audience-feedback/index.js +++ b/ghost/core/core/server/services/audience-feedback/index.js @@ -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}); } } diff --git a/ghost/core/core/server/services/bulk-email/bulk-email-processor.js b/ghost/core/core/server/services/bulk-email/bulk-email-processor.js index 88514e5a71..70e312f9cd 100644 --- a/ghost/core/core/server/services/bulk-email/bulk-email-processor.js +++ b/ghost/core/core/server/services/bulk-email/bulk-email-processor.js @@ -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]; diff --git a/ghost/core/core/server/services/mega/feedback-buttons.js b/ghost/core/core/server/services/mega/feedback-buttons.js new file mode 100644 index 0000000000..763cc38631 --- /dev/null +++ b/ghost/core/core/server/services/mega/feedback-buttons.js @@ -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 (` + + +

What did you think of this post?

+ + + ${likeButtonHtml} + ${dislikeButtonHtml} + +
+ + + `); +}; + +function getButtonHtml(href, buttonText, accentColor) { + const color = new Color(accentColor); + const bgColor = `${accentColor}10`; + const textColor = color.darken(0.6).hex(); + + return (` + + + + + +
+ + ${buttonText} + +
+ + `); +} + +module.exports = { + generateLinks, + getTemplate +}; diff --git a/ghost/core/core/server/services/mega/post-email-serializer.js b/ghost/core/core/server/services/mega/post-email-serializer.js index 87d1d3ad52..843742e929 100644 --- a/ghost/core/core/server/services/mega/post-email-serializer.js +++ b/ghost/core/core/server/services/mega/post-email-serializer.js @@ -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 diff --git a/ghost/core/core/server/services/mega/template.js b/ghost/core/core/server/services/mega/template.js index 42d6635e26..d67bcc15d5 100644 --- a/ghost/core/core/server/services/mega/template.js +++ b/ghost/core/core/server/services/mega/template.js @@ -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 ? ` + ${iff(templateSettings.feedbackEnabled, feedbackButtons.getTemplate(templateSettings.accentColor), '')} + diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap index c8989d40bb..677ab84a1b 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap @@ -416,6 +416,8 @@ table.body figcaption a { + +
@@ -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 { + +
@@ -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 { + +
@@ -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 { + +
@@ -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 { + +
@@ -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", diff --git a/ghost/core/test/unit/server/services/mega/post-email-serializer.test.js b/ghost/core/test/unit/server/services/mega/post-email-serializer.test.js index 24816b9d6a..9251935c6d 100644 --- a/ghost/core/test/unit/server/services/mega/post-email-serializer.test.js +++ b/ghost/core/test/unit/server/services/mega/post-email-serializer.test.js @@ -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 = 'Hey %%{first_name}%%, what is up?'; 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('')); assert(!output.html.includes('')); }); + + 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',