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

Added content paywall for newsletters (#15048)

refs TryGhost/Team#1680

- extends the public preview card so that the paywall is shown in newsletters for paid-only posts based on member's access
- adds CTA for paywalled content in newsletters
- the segmentation for paywall only considers free and non-free members, so post with specific `tiers` and `paid-only` access settings are sent to all paid members

Co-authored-by: Djordje Vlaisavljevic <dzvlais@gmail.com>
This commit is contained in:
Rishabh Garg 2022-07-26 19:16:08 +05:30 committed by GitHub
parent a12047b224
commit 9c616fe067
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 308 additions and 8 deletions

View file

@ -7,6 +7,7 @@ const logging = require('@tryghost/logging');
const models = require('../../models');
const mailgunProvider = require('./mailgun');
const sentry = require('../../../shared/sentry');
const labs = require('../../../shared/labs');
const debug = require('@tryghost/debug')('mega');
const postEmailSerializer = require('../mega/post-email-serializer');
@ -170,6 +171,11 @@ module.exports = {
// Load newsletter data on email
await emailBatchModel.relations.email.getLazyRelation('newsletter', {require: false, ...knexOptions});
if (labs.isSet('newsletterPaywall')) {
// 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);

View file

@ -36,7 +36,11 @@ class EmailPreview {
);
});
return emailContent;
return {
subject: emailContent.subject,
html: emailContent.html,
plaintext: emailContent.plaintext
};
}
}

View file

@ -2,15 +2,18 @@ const _ = require('lodash');
const template = require('./template');
const settingsCache = require('../../../shared/settings-cache');
const urlUtils = require('../../../shared/url-utils');
const labs = require('../../../shared/labs');
const moment = require('moment-timezone');
const api = require('../../api').endpoints;
const apiShared = require('../../api').shared;
const {URL} = require('url');
const mobiledocLib = require('../../lib/mobiledoc');
const htmlToPlaintext = require('../../../shared/html-to-plaintext');
const membersService = require('../members');
const {isUnsplashImage, isLocalContentImage} = 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 ALLOWED_REPLACEMENTS = ['first_name'];
@ -74,6 +77,27 @@ const createUnsubscribeUrl = (uuid, newsletterUuid) => {
return unsubscribeUrl.href;
};
/**
* createPostSignupUrl
*
* Takes a post object. Returns the url that should be used to signup from newsletter
*
* @param {Object} post post object
*/
const 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;
};
// NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
const serializePostModel = async (model) => {
// fetch mobiledoc rather than html and plaintext so we can render email-specific contents
@ -297,18 +321,87 @@ const serialize = async (postModel, newsletter, options = {isBrowserPreview: fal
html: formatHtmlForEmail(htmlTemplate),
plaintext: post.plaintext
});
return {
const data = {
subject: post.email_subject || post.title,
html,
plaintext
};
if (labs.isSet('newsletterPaywall')) {
data.post = post;
}
return data;
};
/**
* renderPaywallCTA
*
* outputs html for rendering paywall CTA in newsletter
*
* @param {Object} post Post Object
*/
function renderPaywallCTA(post) {
const accentColor = settingsCache.get('accent_color');
const siteTitle = settingsCache.get('title') || 'Ghost';
const signupUrl = 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 continue reading.</h2>
<p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 480px;">Become a paid member of ${siteTitle} to get access to all
subscriber-only content.</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>`;
}
function renderEmailForSegment(email, memberSegment) {
const cheerio = require('cheerio');
const result = {...email};
/** Checks and hides content for newsletter behind paywall card
* based on member's status and post access
* Adds CTA in case content is hidden.
*/
if (labs.isSet('newsletterPaywall')) {
const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
if (paywallIndex !== -1 && memberSegment) {
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;
const memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
if (!memberHasAccess) {
const postContentEndIdx = result.html.search(/[\s\n\r]+?<!-- POST CONTENT END -->/);
result.html = result.html.slice(0, paywallIndex) + renderPaywallCTA(result.post) + result.html.slice(postContentEndIdx);
result.plaintext = htmlToPlaintext.excerpt(result.html);
}
}
}
const $ = cheerio.load(result.html);
$('[data-gh-segment]').get().forEach((node) => {
@ -322,6 +415,7 @@ function renderEmailForSegment(email, memberSegment) {
result.html = formatHtmlForEmail($.html());
result.plaintext = htmlToPlaintext.email(result.html);
delete result.post;
return result;
}
@ -329,6 +423,7 @@ function renderEmailForSegment(email, memberSegment) {
module.exports = {
serialize,
createUnsubscribeUrl,
createPostSignupUrl,
renderEmailForSegment,
parseReplacements,
// Export for tests

View file

@ -1,11 +1,20 @@
const labs = require('../../../shared/labs');
const getSegmentsFromHtml = (html) => {
const cheerio = require('cheerio');
const $ = cheerio.load(html);
const allSegments = $('[data-gh-segment]')
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 (labs.isSet('newsletterPaywall') && html.indexOf('<!--members-only-->') !== -1) {
allSegments = allSegments.concat(['status:free', 'status:-free']);
}
// only return unique elements
return [...new Set(allSegments)];
};

View file

@ -25,7 +25,8 @@ const BETA_FEATURES = [
const ALPHA_FEATURES = [
'urlCache',
'beforeAfterCard',
'comments'
'comments',
'newsletterPaywall'
];
module.exports.GA_KEYS = [...GA_FEATURES];

View file

@ -230,7 +230,7 @@ Object {
},
Object {
"key": "labs",
"value": "{\\"activitypub\\":true,\\"urlCache\\":true,\\"beforeAfterCard\\":true,\\"comments\\":true,\\"members\\":true}",
"value": "{\\"activitypub\\":true,\\"urlCache\\":true,\\"beforeAfterCard\\":true,\\"comments\\":true,\\"newsletterPaywall\\":true,\\"members\\":true}",
},
Object {
"key": "slack_url",
@ -280,7 +280,7 @@ exports[`Settings API Browse Can request all settings 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": "3379",
"content-length": "3406",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",

View file

@ -3,8 +3,10 @@ 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, _getTemplateSettings, createUnsubscribeUrl} = require('../../../../../core/server/services/mega/post-email-serializer');
const {parseReplacements, renderEmailForSegment, _getTemplateSettings, createUnsubscribeUrl, createPostSignupUrl} = require('../../../../../core/server/services/mega/post-email-serializer');
describe('Post Email Serializer', function () {
it('creates replacement pattern for valid format and value', function () {
@ -37,6 +39,10 @@ describe('Post Email Serializer', function () {
});
describe('renderEmailForSegment', function () {
afterEach(function () {
sinon.restore();
});
it('shouldn\'t change an email that has no member segment', function () {
const email = {
otherProperty: true,
@ -84,6 +90,122 @@ describe('Post Email Serializer', function () {
output.html.should.equal('hello');
output.plaintext.should.equal('hello');
});
it('should show paywall 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: '<p>Free content</p><!--members-only--><p>Members content</p>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:free');
output.html.should.containEql(`<p>Free content</p>`);
output.html.should.containEql(`Subscribe to continue reading`);
output.html.should.containEql(`https://site.com/blah/#/portal/signup`);
output.html.should.not.containEql(`<p>Members content</p>`);
output.plaintext.should.containEql(`Free content`);
output.plaintext.should.containEql(`Subscribe to continue reading`);
output.plaintext.should.containEql(`https://site.com/blah/#/portal/signup`);
output.plaintext.should.not.containEql(`Members content`);
});
it('should show full cta 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: '<p>Free content</p><!--members-only--><p>Members content</p>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:-free');
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
output.plaintext.should.equal(`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: '<p>Free content</p><!--members-only--><p>Members content</p>',
plaintext: 'Free content. Members content'
};
let output = renderEmailForSegment(email, 'status:free');
output.html.should.containEql(`<p>Free content</p>`);
output.html.should.containEql(`Subscribe to continue reading`);
output.html.should.containEql(`https://site.com/blah/#/portal/signup`);
output.html.should.not.containEql(`<p>Members content</p>`);
output.plaintext.should.containEql(`Free content`);
output.plaintext.should.containEql(`Subscribe to continue reading`);
output.plaintext.should.containEql(`https://site.com/blah/#/portal/signup`);
output.plaintext.should.not.containEql(`Members content`);
});
it('should show full cta 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');
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
output.plaintext.should.equal(`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');
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
output.plaintext.should.equal(`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');
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
output.plaintext.should.equal(`Free content\n\nMembers content`);
});
});
describe('createUnsubscribeUrl', function () {
@ -114,6 +236,35 @@ describe('Post Email Serializer', function () {
});
});
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'
});
unsubscribeUrl.should.eql('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'
});
unsubscribeUrl.should.eql('https://site.com/test/#/portal/signup');
});
});
describe('getTemplateSettings', function () {
before(function () {
models.init();

View file

@ -1,8 +1,14 @@
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>';
@ -41,6 +47,34 @@ describe('MEGA: Segment Parser', function () {
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>';