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 41edd456a7..183c3c4711 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 @@ -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); diff --git a/ghost/core/core/server/services/mega/email-preview.js b/ghost/core/core/server/services/mega/email-preview.js index 17e6327f17..b0d69e2599 100644 --- a/ghost/core/core/server/services/mega/email-preview.js +++ b/ghost/core/core/server/services/mega/email-preview.js @@ -36,7 +36,11 @@ class EmailPreview { ); }); - return emailContent; + return { + subject: emailContent.subject, + html: emailContent.html, + plaintext: emailContent.plaintext + }; } } 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 db5cf8a748..296a810fbe 100644 --- a/ghost/core/core/server/services/mega/post-email-serializer.js +++ b/ghost/core/core/server/services/mega/post-email-serializer.js @@ -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 `
Become a paid member of ${siteTitle} to get access to all + subscriber-only content.
++ Subscribe + + | +
Free content
Members content
', + plaintext: 'Free content. Members content' + }; + + let output = renderEmailForSegment(email, 'status:free'); + output.html.should.containEql(`Free content
`); + output.html.should.containEql(`Subscribe to continue reading`); + output.html.should.containEql(`https://site.com/blah/#/portal/signup`); + output.html.should.not.containEql(`Members content
`); + + 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: 'Free content
Members content
', + plaintext: 'Free content. Members content' + }; + + let output = renderEmailForSegment(email, 'status:-free'); + output.html.should.equal(`Free content
Members content
`); + 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: 'Free content
Members content
', + plaintext: 'Free content. Members content' + }; + + let output = renderEmailForSegment(email, 'status:free'); + output.html.should.containEql(`Free content
`); + output.html.should.containEql(`Subscribe to continue reading`); + output.html.should.containEql(`https://site.com/blah/#/portal/signup`); + output.html.should.not.containEql(`Members content
`); + + 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: 'Free content
Members content
', + plaintext: 'Free content. Members content' + }; + + let output = renderEmailForSegment(email, 'status:-free'); + output.html.should.equal(`Free content
Members content
`); + 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: 'Free content
Members content
', + plaintext: 'Free content. Members content' + }; + + let output = renderEmailForSegment(email, 'status:free'); + output.html.should.equal(`Free content
Members content
`); + 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: 'Free content
Members content
', + plaintext: 'Free content. Members content' + }; + + let output = renderEmailForSegment(email, 'status:-free'); + output.html.should.equal(`Free content
Members content
`); + 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(); diff --git a/ghost/core/test/unit/server/services/mega/segment-parser.test.js b/ghost/core/test/unit/server/services/mega/segment-parser.test.js index 2c64da2217..5a4a42c089 100644 --- a/ghost/core/test/unit/server/services/mega/segment-parser.test.js +++ b/ghost/core/test/unit/server/services/mega/segment-parser.test.js @@ -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 = 'Plain html with no replacements
Free content
Members content
'; + + 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 = ` +Text for paid
Text for free
Another message for paid member
Free content
Members content
+ `; + + 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 = 'Plain html with no replacements