const {EmailRenderer} = require('../'); const assert = require('assert'); const cheerio = require('cheerio'); const {createModel, createModelClass} = require('./utils'); const linkReplacer = require('@tryghost/link-replacer'); const sinon = require('sinon'); const logging = require('@tryghost/logging'); const {HtmlValidate} = require('html-validate'); const {DateTime} = require('luxon'); function validateHtml(html) { const htmlvalidate = new HtmlValidate({ extends: [ 'html-validate:document', 'html-validate:standard' ], rules: { // We need deprecated attrs for legacy tables in older email clients 'no-deprecated-attr': 'off', // Don't care that the first isn't

'heading-level': 'off' }, elements: [ 'html5', // By default, html-validate requires the 'lang' attribute on the tag. We don't really want that for now. { html: { attributes: { lang: { required: false } } } } ] }); const report = htmlvalidate.validateString(html); // 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 = html.split('\n'); const messages = report.results[0].messages; for (const item of messages) { if (item.severity !== 2) { // Ignore warnings continue; } const start = Math.max(item.line - 4, 0); const end = Math.min(item.line + 4, lines.length - 1); const _html = lines.slice(start, end).map(l => l.trim()).join('\n'); parsedErrors.push(`${item.ruleId}: ${item.message}\n At line ${item.line}, col ${item.column}\n HTML-snippet:\n${_html}`); } } // Fail if invalid HTML assert.equal(report.valid, true, 'Expected valid HTML without warnings, got errors:\n' + parsedErrors.join('\n\n')); } describe('Email renderer', function () { let logStub; beforeEach(function () { logStub = sinon.stub(logging, 'error'); }); afterEach(function () { sinon.restore(); }); describe('buildReplacementDefinitions', function () { let emailRenderer; let newsletter; let member; beforeEach(function () { emailRenderer = new EmailRenderer({ urlUtils: { urlFor: () => 'http://example.com/subdirectory/' }, labs: { isSet: () => true }, settingsCache: { get: (key) => { if (key === 'timezone') { return 'UTC'; } } } }); newsletter = createModel({ uuid: 'newsletteruuid' }); member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'free' }; }); it('returns an empty list of replacements if nothing is used', function () { const html = 'Hello world'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 0); }); it('returns a replacement if it is used', function () { const html = 'Hello world %%{uuid}%%'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{uuid\\}%%/g'); assert.equal(replacements[0].id, 'uuid'); assert.equal(replacements[0].getValue(member), 'myuuid'); }); it('returns a replacement only once if used multiple times', function () { const html = 'Hello world %%{uuid}%% And %%{uuid}%%'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{uuid\\}%%/g'); assert.equal(replacements[0].id, 'uuid'); assert.equal(replacements[0].getValue(member), 'myuuid'); }); it('returns correct first name', function () { const html = 'Hello %%{first_name}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{first_name\\}%%/g'); assert.equal(replacements[0].id, 'first_name'); assert.equal(replacements[0].getValue(member), 'Test'); }); it('returns correct unsubscribe url', function () { const html = 'Hello %%{unsubscribe_url}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{unsubscribe_url\\}%%/g'); assert.equal(replacements[0].id, 'unsubscribe_url'); assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=myuuid&newsletter=newsletteruuid`); }); it('returns correct name', function () { const html = 'Hello %%{name}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{name\\}%%/g'); assert.equal(replacements[0].id, 'name'); assert.equal(replacements[0].getValue(member), 'Test User'); }); it('returns hidden class for missing name', function () { member.name = ''; const html = 'Hello %%{name_class}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{name_class\\}%%/g'); assert.equal(replacements[0].id, 'name_class'); assert.equal(replacements[0].getValue(member), 'hidden'); }); it('returns empty class for available name', function () { const html = 'Hello %%{name_class}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{name_class\\}%%/g'); assert.equal(replacements[0].id, 'name_class'); assert.equal(replacements[0].getValue(member), ''); }); it('returns correct email', function () { const html = 'Hello %%{email}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{email\\}%%/g'); assert.equal(replacements[0].id, 'email'); assert.equal(replacements[0].getValue(member), 'test@example.com'); }); it('returns correct status', function () { const html = 'Hello %%{status}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{status\\}%%/g'); assert.equal(replacements[0].id, 'status'); assert.equal(replacements[0].getValue(member), 'free'); }); it('returns mapped complimentary status', function () { member.status = 'comped'; const html = 'Hello %%{status}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{status\\}%%/g'); assert.equal(replacements[0].id, 'status'); assert.equal(replacements[0].getValue(member), 'complimentary'); }); it('returns manage_account_url', function () { const html = 'Hello %%{manage_account_url}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{manage_account_url\\}%%/g'); assert.equal(replacements[0].id, 'manage_account_url'); assert.equal(replacements[0].getValue(member), 'http://example.com/subdirectory/#/portal/account'); }); it('returns status_text', function () { const html = 'Hello %%{status_text}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{status_text\\}%%/g'); assert.equal(replacements[0].id, 'status_text'); assert.equal(replacements[0].getValue(member), 'You are currently subscribed to the free plan.'); }); it('returns correct createdAt', function () { const html = 'Hello %%{created_at}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{created_at\\}%%/g'); assert.equal(replacements[0].id, 'created_at'); assert.equal(replacements[0].getValue(member), '13 March 2023'); }); it('returns missing created at', function () { member.createdAt = null; const html = 'Hello %%{created_at}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{created_at\\}%%/g'); assert.equal(replacements[0].id, 'created_at'); assert.equal(replacements[0].getValue(member), ''); }); it('supports fallback values', function () { const html = 'Hey %%{first_name, "there"}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{first_name, (?:"|")there(?:"|")\\}%%/g'); assert.equal(replacements[0].id, 'first_name_2'); assert.equal(replacements[0].getValue(member), 'Test'); // In case of empty name assert.equal(replacements[0].getValue({name: ''}), 'there'); }); it('supports combination of multiple fallback values', function () { const html = 'Hey %%{first_name, "there"}%%, %%{first_name, "member"}%% %%{first_name}%% %%{first_name, "there"}%%'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 3); assert.equal(replacements[0].token.toString(), '/%%\\{first_name, (?:"|")there(?:"|")\\}%%/g'); assert.equal(replacements[0].id, 'first_name_2'); assert.equal(replacements[0].getValue(member), 'Test'); // In case of empty name assert.equal(replacements[0].getValue({name: ''}), 'there'); assert.equal(replacements[1].token.toString(), '/%%\\{first_name, (?:"|")member(?:"|")\\}%%/g'); assert.equal(replacements[1].id, 'first_name_3'); assert.equal(replacements[1].getValue(member), 'Test'); // In case of empty name assert.equal(replacements[1].getValue({name: ''}), 'member'); assert.equal(replacements[2].token.toString(), '/%%\\{first_name\\}%%/g'); assert.equal(replacements[2].id, 'first_name'); assert.equal(replacements[2].getValue(member), 'Test'); // In case of empty name assert.equal(replacements[2].getValue({name: ''}), ''); }); }); describe('getMemberStatusText', function () { let emailRenderer; beforeEach(function () { emailRenderer = new EmailRenderer({ urlUtils: { urlFor: () => 'http://example.com/subdirectory/' }, labs: { isSet: () => true }, settingsCache: { get: (key) => { if (key === 'timezone') { return 'UTC'; } } } }); }); it('Returns for free member', function () { const member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'free' }; const result = emailRenderer.getMemberStatusText(member); assert.equal(result, 'You are currently subscribed to the free plan.'); }); it('Returns for active paid member', function () { const member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'paid', subscriptions: [ { status: 'active', current_period_end: new Date(2023, 2, 13, 12, 0), cancel_at_period_end: false } ] }; const result = emailRenderer.getMemberStatusText(member); assert.equal(result, 'Your subscription will renew on 13 March 2023.'); }); it('Returns for canceled paid member', function () { const member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'paid', subscriptions: [ { status: 'active', current_period_end: new Date(2023, 2, 13, 12, 0), cancel_at_period_end: true } ] }; const result = emailRenderer.getMemberStatusText(member); assert.equal(result, 'Your subscription has been canceled and will expire on 13 March 2023. You can resume your subscription via your account settings.'); }); it('Returns for expired paid member', function () { const member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'paid', subscriptions: [ { status: 'canceled', current_period_end: new Date(2023, 2, 13, 12, 0), cancel_at_period_end: true } ], tiers: [] }; const result = emailRenderer.getMemberStatusText(member); assert.equal(result, 'Your subscription has expired.'); }); it('Returns for trialing paid member', function () { const member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'paid', subscriptions: [ { status: 'trialing', trial_end_at: new Date(2050, 2, 13, 12, 0), current_period_end: new Date(2023, 2, 13, 12, 0), cancel_at_period_end: false } ], tiers: [] }; const result = emailRenderer.getMemberStatusText(member); assert.equal(result, 'Your free trial ends on 13 March 2050, at which time you will be charged the regular price. You can always cancel before then.'); }); it('Returns for infinite complimentary member', function () { const member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'comped', subscriptions: [], tiers: [ { name: 'Silver', expiry_at: null } ] }; const result = emailRenderer.getMemberStatusText(member); assert.equal(result, ''); }); it('Returns for expiring complimentary member', function () { const member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'comped', subscriptions: [], tiers: [ { name: 'Silver', expiry_at: new Date(2050, 2, 13, 12, 0) } ] }; const result = emailRenderer.getMemberStatusText(member); assert.equal(result, 'Your subscription will expire on 13 March 2050.'); }); it('Returns for a paid member without subscriptions', function () { const member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'paid', subscriptions: [], tiers: [ { name: 'Silver', expiry_at: new Date(2050, 2, 13, 12, 0) } ] }; const result = emailRenderer.getMemberStatusText(member); assert.equal(result, 'Your subscription has been canceled and will expire on 13 March 2050. You can resume your subscription via your account settings.'); }); it('Returns for an infinte paid member without subscriptions', function () { const member = { id: '456', uuid: 'myuuid', name: 'Test User', email: 'test@example.com', createdAt: new Date(2023, 2, 13, 12, 0), status: 'paid', subscriptions: [], tiers: [ { name: 'Silver', expiry_at: null } ] }; const result = emailRenderer.getMemberStatusText(member); assert.equal(result, ''); }); }); describe('getSubject', function () { const emailRenderer = new EmailRenderer({ urlUtils: { urlFor: () => 'http://example.com' }, labs: { isSet: () => true } }); it('returns a post with correct subject from meta', function () { const post = createModel({ posts_meta: createModel({ email_subject: 'Test Newsletter' }), title: 'Sample Post', loaded: ['posts_meta'] }); let response = emailRenderer.getSubject(post); response.should.equal('Test Newsletter'); }); it('returns a post with correct subject from title', function () { const post = createModel({ posts_meta: createModel({ email_subject: '' }), title: 'Sample Post', loaded: ['posts_meta'] }); let response = emailRenderer.getSubject(post); response.should.equal('Sample Post'); }); }); describe('getFromAddress', function () { let siteTitle = 'Test Blog'; let emailRenderer = new EmailRenderer({ settingsCache: { get: (key) => { if (key === 'title') { return siteTitle; } } }, settingsHelpers: { getNoReplyAddress: () => { return 'reply@example.com'; } }, labs: { isSet: () => true } }); it('returns correct from address for newsletter', function () { const newsletter = createModel({ sender_email: 'ghost@example.com', sender_name: 'Ghost' }); const response = emailRenderer.getFromAddress({}, newsletter); response.should.equal('"Ghost" '); }); it('defaults to site title and domain', function () { const newsletter = createModel({ sender_email: '', sender_name: '' }); const response = emailRenderer.getFromAddress({}, newsletter); response.should.equal('"Test Blog" '); }); it('changes localhost domain to proper domain in development', function () { const newsletter = createModel({ sender_email: 'example@localhost', sender_name: '' }); const response = emailRenderer.getFromAddress({}, newsletter); response.should.equal('"Test Blog" '); }); it('ignores empty sender names', function () { siteTitle = ''; const newsletter = createModel({ sender_email: 'example@example.com', sender_name: '' }); const response = emailRenderer.getFromAddress({}, newsletter); response.should.equal('example@example.com'); }); }); describe('getReplyToAddress', function () { let emailRenderer = new EmailRenderer({ settingsCache: { get: (key) => { if (key === 'title') { return 'Test Blog'; } } }, settingsHelpers: { getMembersSupportAddress: () => { return 'support@example.com'; }, getNoReplyAddress: () => { return 'reply@example.com'; } }, labs: { isSet: () => true } }); it('returns support address', function () { const newsletter = createModel({ sender_email: 'ghost@example.com', sender_name: 'Ghost', sender_reply_to: 'support' }); const response = emailRenderer.getReplyToAddress({}, newsletter); response.should.equal('support@example.com'); }); it('returns correct reply to address for newsletter', function () { const newsletter = createModel({ sender_email: 'ghost@example.com', sender_name: 'Ghost', sender_reply_to: 'newsletter' }); const response = emailRenderer.getReplyToAddress({}, newsletter); response.should.equal(`"Ghost" `); }); }); describe('getSegments', function () { let emailRenderer = new EmailRenderer({ renderers: { lexical: { render: () => { return '

Lexical Test

'; } }, mobiledoc: { render: () => { return '

Mobiledoc Test

'; } } }, getPostUrl: () => { return 'http://example.com/post-id'; }, labs: { isSet: () => true } }); it('returns correct empty segment for post', function () { let post = { get: (key) => { if (key === 'lexical') { return '{}'; } } }; let response = emailRenderer.getSegments(post); response.should.eql([null]); post = { get: (key) => { if (key === 'mobiledoc') { return '{}'; } } }; response = emailRenderer.getSegments(post); response.should.eql([null]); }); it('returns correct segments for post with members only card', function () { emailRenderer = new EmailRenderer({ renderers: { lexical: { render: () => { return '

Lexical Test members only section

'; } } }, getPostUrl: () => { return 'http://example.com/post-id'; }, labs: { isSet: () => true } }); let post = { get: (key) => { if (key === 'lexical') { return '{}'; } } }; let response = emailRenderer.getSegments(post); response.should.eql(['status:free', 'status:-free']); }); it('returns correct segments for post with email card', function () { emailRenderer = new EmailRenderer({ renderers: { lexical: { render: () => { return '
Lexical Test
members only section
'; } } }, getPostUrl: () => { return 'http://example.com/post-id'; }, labs: { isSet: () => true } }); let post = { get: (key) => { if (key === 'lexical') { return '{}'; } } }; let response = emailRenderer.getSegments(post); response.should.eql(['status:free', 'status:-free']); }); }); describe('renderBody', function () { let renderedPost = '

Lexical Test

'; let postUrl = 'http://example.com'; let customSettings = {}; let emailRenderer = new EmailRenderer({ audienceFeedbackService: { buildLink: (_uuid, _postId, score) => { return new URL('http://feedback-link.com/?score=' + encodeURIComponent(score) + '&uuid=' + encodeURIComponent(_uuid)); } }, urlUtils: { urlFor: (type) => { if (type === 'image') { return 'http://icon.example.com'; } return 'http://example.com/subdirectory'; }, isSiteUrl: (u) => { return u.hostname === 'example.com'; } }, settingsCache: { get: (key) => { if (customSettings[key]) { return customSettings[key]; } if (key === 'accent_color') { return '#ffffff'; } if (key === 'timezone') { return 'Etc/UTC'; } if (key === 'title') { return 'Test Blog'; } if (key === 'icon') { return 'ICON'; } } }, getPostUrl: () => { return postUrl; }, renderers: { lexical: { render: () => { return renderedPost; } }, mobiledoc: { render: () => { return '

Mobiledoc Test

'; } } }, linkReplacer, memberAttributionService: { addPostAttributionTracking: (u) => { u.searchParams.append('post_tracking', 'added'); return u; } }, linkTracking: { service: { addTrackingToUrl: (u, _post, uuid) => { return new URL('http://tracked-link.com/?m=' + encodeURIComponent(uuid) + '&url=' + encodeURIComponent(u.href)); } } }, outboundLinkTagger: { addToUrl: (u, newsletter) => { u.searchParams.append('source_tracking', newsletter?.get('name') ?? 'site'); return u; } }, labs: { isSet: () => true } }); let basePost; beforeEach(function () { basePost = { lexical: '{}', visibility: 'public', title: 'Test Post', plaintext: 'Test plaintext for post', custom_excerpt: null, authors: [ createModel({ name: 'Test Author' }) ], posts_meta: createModel({ feature_image_alt: null, feature_image_caption: null }), loaded: ['posts_meta'] }; postUrl = 'http://example.com'; customSettings = {}; }); it('returns feedback buttons and unsubcribe links', async function () { const post = createModel(basePost); const newsletter = createModel({ header_image: null, name: 'Test Newsletter', show_badge: false, feedback_enabled: true, show_post_title_section: true }); const segment = null; const options = {}; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); const $ = cheerio.load(response.html); response.plaintext.should.containEql('Test Post'); // Unsubscribe button included response.plaintext.should.containEql('Unsubscribe [%%{unsubscribe_url}%%]'); response.html.should.containEql('Unsubscribe'); response.replacements.length.should.eql(2); response.replacements.should.match([ { id: 'uuid' }, { id: 'unsubscribe_url', token: /%%\{unsubscribe_url\}%%/g } ]); response.plaintext.should.containEql('http://example.com'); should($('.preheader').text()).eql('Test plaintext for post'); response.html.should.containEql('Test Post'); response.html.should.containEql('http://example.com'); // Does not include Ghost badge response.html.should.not.containEql('https://ghost.org/'); // Test feedback buttons included response.html.should.containEql('http://feedback-link.com/?score=1'); response.html.should.containEql('http://feedback-link.com/?score=0'); }); it('uses custom excerpt as preheader', async function () { const post = createModel({...basePost, custom_excerpt: 'Custom excerpt'}); const newsletter = createModel({ header_image: null, name: 'Test Newsletter', show_badge: false, feedback_enabled: true, show_post_title_section: true }); const segment = null; const options = {}; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); const $ = cheerio.load(response.html); should($('.preheader').text()).eql('Custom excerpt'); }); it('only includes first author if more than 2', async function () { const post = createModel({...basePost, authors: [ createModel({ name: 'A' }), createModel({ name: 'B' }), createModel({ name: 'C' }) ]}); const newsletter = createModel({ header_image: null, name: 'Test Newsletter', show_badge: false, feedback_enabled: true, show_post_title_section: true }); const segment = null; const options = {}; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); assert.match(response.html, /By A & 2 others/); assert.match(response.plaintext, /By A & 2 others/); }); it('includes header icon, title, name', async function () { const post = createModel(basePost); const newsletter = createModel({ header_image: null, name: 'Test Newsletter', show_badge: false, feedback_enabled: true, show_header_icon: true, show_header_title: true, show_header_name: true, show_post_title_section: true }); const segment = null; const options = {}; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); response.html.should.containEql('http://icon.example.com'); assert.match(response.html, /class="site-title"[^>]*?>Test Blog/); assert.match(response.html, /class="site-subtitle"[^>]*?>Test Newsletter/); }); it('includes header icon and name', async function () { const post = createModel(basePost); const newsletter = createModel({ header_image: null, name: 'Test Newsletter', show_badge: false, feedback_enabled: true, show_header_icon: true, show_header_title: false, show_header_name: true, show_post_title_section: true }); const segment = null; const options = {}; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); response.html.should.containEql('http://icon.example.com'); assert.match(response.html, /class="site-title"[^>]*?>Test Newsletter/); }); it('includes Ghost badge if enabled', async function () { const post = createModel(basePost); const newsletter = createModel({ header_image: null, name: 'Test Newsletter', show_badge: true, feedback_enabled: false }); const segment = null; const options = {}; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); // Does include include Ghost badge assert.match(response.html, /https:\/\/ghost.org\//); // Test feedback buttons not included response.html.should.not.containEql('http://feedback-link.com/?score=1'); response.html.should.not.containEql('http://feedback-link.com/?score=0'); }); it('includes newsletter footer as raw html', async function () { const post = createModel(basePost); const newsletter = createModel({ header_image: null, name: 'Test Newsletter', show_badge: true, feedback_enabled: false, footer_content: '

Test footer

' }); const segment = null; const options = {}; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); // Test footer response.html.should.containEql('Test footer

'); // begin tag skipped because style is inlined in that tag response.plaintext.should.containEql('Test footer'); }); it('replaces all links except the unsubscribe and feedback links', async function () { const post = createModel(basePost); const newsletter = createModel({ header_image: null, name: 'Test Newsletter', show_badge: true, feedback_enabled: true, show_post_title_section: true }); const segment = null; const options = { clickTrackingEnabled: true }; renderedPost = '

Lexical Test

Hello

'; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); // Check all links have domain tracked-link.com const $ = cheerio.load(response.html); const links = []; for (const link of $('a').toArray()) { const href = $(link).attr('href'); links.push(href); if (href.includes('unsubscribe_url')) { href.should.eql('%%{unsubscribe_url}%%'); } else if (href.includes('feedback-link.com')) { href.should.containEql('%%{uuid}%%'); } else { href.should.containEql('tracked-link.com'); href.should.containEql('m=%%{uuid}%%'); } } // Update the following array when you make changes to the email template, check if replacements are correct for each newly added link. assert.deepEqual(links, [ `http://tracked-link.com/?m=%%{uuid}%%&url=http%3A%2F%2Fexample.com%2F%3Fsource_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`, `http://tracked-link.com/?m=%%{uuid}%%&url=http%3A%2F%2Fexample.com%2F%3Fsource_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`, `http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexternal-domain.com%2F%3Fref%3D123%26source_tracking%3Dsite`, `http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexample.com%2F%3Fref%3D123%26source_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`, `http://feedback-link.com/?score=1&uuid=%%{uuid}%%`, `http://feedback-link.com/?score=0&uuid=%%{uuid}%%`, `http://feedback-link.com/?score=1&uuid=%%{uuid}%%`, `http://feedback-link.com/?score=0&uuid=%%{uuid}%%`, `%%{unsubscribe_url}%%`, `http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fghost.org%2F%3Fsource_tracking%3Dsite` ]); // Check uuid in replacements response.replacements.length.should.eql(2); response.replacements[0].id.should.eql('uuid'); response.replacements[0].token.should.eql(/%%\{uuid\}%%/g); response.replacements[1].id.should.eql('unsubscribe_url'); response.replacements[1].token.should.eql(/%%\{unsubscribe_url\}%%/g); }); it('removes data-gh-segment and renders paywall', async function () { renderedPost = '
Lexical Test
members only section
some text for both finishing part only for members'; let post = { related: () => { return null; }, get: (key) => { if (key === 'lexical') { return '{}'; } if (key === 'visibility') { return 'paid'; } if (key === 'title') { return 'Test Post'; } }, getLazyRelation: () => { return { models: [{ get: (key) => { if (key === 'name') { return 'Test Author'; } } }] }; } }; let newsletter = { get: (key) => { if (key === 'header_image') { return null; } if (key === 'name') { return 'Test Newsletter'; } if (key === 'badge') { return false; } if (key === 'feedback_enabled') { return true; } return false; } }; let options = {}; let response = await emailRenderer.renderBody( post, newsletter, 'status:free', options ); response.plaintext.should.containEql('Test Post'); response.plaintext.should.containEql('Unsubscribe [%%{unsubscribe_url}%%]'); response.plaintext.should.containEql('http://example.com'); response.html.should.containEql('Test Post'); response.html.should.containEql('Unsubscribe'); response.html.should.containEql('http://example.com'); response.replacements.length.should.eql(2); response.replacements.should.match([ { id: 'uuid' }, { id: 'unsubscribe_url', token: /%%\{unsubscribe_url\}%%/g } ]); response.html.should.not.containEql('members only section'); response.html.should.containEql('some text for both'); response.html.should.not.containEql('finishing part only for members'); response.html.should.containEql('Become a paid member of Test Blog to get access to all'); let responsePaid = await emailRenderer.renderBody( post, newsletter, 'status:-free', options ); responsePaid.html.should.containEql('members only section'); responsePaid.html.should.containEql('some text for both'); responsePaid.html.should.containEql('finishing part only for members'); responsePaid.html.should.not.containEql('Become a paid member of Test Blog to get access to all'); }); it('should output valid HTML and escape HTML characters in mobiledoc', async function () { const post = createModel({ ...basePost, title: 'This is\' a blog po"st test <3', excerpt: 'This is a blog post test <3', authors: [ createModel({ name: 'This is a blog post test <3' }) ], posts_meta: createModel({ feature_image_alt: 'This is a blog post test <3', feature_image_caption: 'This is escaped in the frontend' }) }); postUrl = 'https://testpost.com/t&es<3t-post"/'; customSettings = { icon: 'icon2<3' }; const newsletter = createModel({ feedback_enabled: true, name: 'My newsletter <3', header_image: 'https://testpost.com/test-post/', 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, // Note: we don't need to check the footer content because this should contain valid HTML (not text) footer_content: 'Footer content with valid HTML' }); const segment = null; const options = {}; const response = await emailRenderer.renderBody( post, newsletter, segment, options ); validateHtml(response.html); // Check footer content is not escaped assert.equal(response.html.includes('Footer content with valid HTML'), true, 'Should include footer content without escaping'); // Check doesn't contain the non escaped string '<3' assert.equal(response.html.includes('<3'), false, 'Should escape HTML characters'); }); }); describe('getTemplateData', function () { let settings = {}; let labsEnabled = true; let emailRenderer; beforeEach(function () { settings = {}; labsEnabled = true; emailRenderer = new EmailRenderer({ audienceFeedbackService: { buildLink: (_uuid, _postId, score) => { return new URL('http://feedback-link.com/?score=' + encodeURIComponent(score) + '&uuid=' + encodeURIComponent(_uuid)); } }, urlUtils: { urlFor: (type) => { if (type === 'image') { return 'http://icon.example.com'; } return 'http://example.com/subdirectory'; }, isSiteUrl: (u) => { return u.hostname === 'example.com'; } }, settingsCache: { get: (key) => { return settings[key]; } }, getPostUrl: () => { return 'http://example.com'; }, labs: { isSet: () => labsEnabled }, models: { Post: createModelClass({ findAll: [ { title: 'Test Post 1', published_at: new Date('2018-01-01T00:00:00.000Z'), feature_image: 'http://example.com/image.jpg' }, { title: 'Test Post 2', published_at: new Date('2018-01-01T00:00:00.000Z'), feature_image: null }, { title: 'Test Post 3', published_at: null, // required for full test coverage feature_image: null } ] }) } }); }); it('uses default accent color', async function () { const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'] }); const newsletter = createModel({}); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.accentColor, '#15212A'); }); it('handles invalid accent color', async function () { const html = ''; settings.accent_color = '#QR'; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'] }); const newsletter = createModel({}); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.accentColor, '#15212A'); }); it('uses post published_at', async function () { const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({}); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.post.publishedAt, '1 Jan 1970'); }); it('show feature image if post has feature image', async function () { const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0), feature_image: 'http://example.com/image.jpg' }); const newsletter = createModel({ show_feature_image: true }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.showFeatureImage, true); }); it('uses newsletter font styles', async function () { const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif' }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.deepEqual(data.classes, { title: 'post-title post-title-serif post-title-left', titleLink: 'post-title-link post-title-link-left', meta: 'post-meta post-meta-left', body: 'post-content-sans-serif' }); }); it('show comment CTA is enabled if labs disabled', async function () { labsEnabled = false; settings.comments_enabled = 'all'; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_comment_cta: true }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.newsletter.showCommentCta, true); }); it('show comment CTA is disabled if comments disabled', async function () { labsEnabled = true; settings.comments_enabled = 'off'; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_comment_cta: true }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.newsletter.showCommentCta, false); }); it('show comment CTA is disabled if disabled', async function () { labsEnabled = true; settings.comments_enabled = 'all'; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_comment_cta: false }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.newsletter.showCommentCta, false); }); it('show comment CTA is enabled if all enabled', async function () { labsEnabled = true; settings.comments_enabled = 'all'; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_comment_cta: true }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.newsletter.showCommentCta, true); }); it('showSubscriptionDetails is disabled if labs disabled', async function () { labsEnabled = false; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_subscription_details: true }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.newsletter.showSubscriptionDetails, false); }); it('showSubscriptionDetails works is enabled', async function () { labsEnabled = true; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_subscription_details: true }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.newsletter.showSubscriptionDetails, true); }); it('showSubscriptionDetails can be disabled', async function () { labsEnabled = true; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_subscription_details: false }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.newsletter.showSubscriptionDetails, false); }); it('latestPosts can be disabled', async function () { labsEnabled = true; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_latest_posts: false }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.deepEqual(data.latestPosts, []); }); it('latestPosts can be disabled via labs', async function () { labsEnabled = false; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_latest_posts: true }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.deepEqual(data.latestPosts, []); }); it('latestPosts can be enabled', async function () { labsEnabled = true; const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'], published_at: new Date(0) }); const newsletter = createModel({ title_font_category: 'serif', title_alignment: 'left', body_font_category: 'sans_serif', show_latest_posts: true }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.deepEqual(data.latestPosts, [ { featureImage: 'http://example.com/image.jpg', featureImageWidth: 0, featureImageHeight: null, publishedAt: '1 Jan 2018', title: 'Test Post 1', url: 'http://example.com' }, { featureImage: null, featureImageWidth: 0, featureImageHeight: null, publishedAt: '1 Jan 2018', title: 'Test Post 2', url: 'http://example.com' }, { featureImage: null, featureImageWidth: 0, featureImageHeight: null, publishedAt: DateTime.local().setZone('UTC').setLocale('en-gb').toLocaleString({ year: 'numeric', month: 'short', day: 'numeric' }), title: 'Test Post 3', url: 'http://example.com' } ]); }); }); describe('createUnsubscribeUrl', function () { it('includes member uuid and newsletter id', async function () { const emailRenderer = new EmailRenderer({ urlUtils: { urlFor() { return 'http://example.com/subdirectory'; } } }); const response = await emailRenderer.createUnsubscribeUrl('memberuuid', { newsletterUuid: 'newsletteruuid' }); assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&newsletter=newsletteruuid`); }); it('includes comments', async function () { const emailRenderer = new EmailRenderer({ urlUtils: { urlFor() { return 'http://example.com/subdirectory'; } } }); const response = await emailRenderer.createUnsubscribeUrl('memberuuid', { comments: true }); assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&comments=1`); }); it('works for previews', async function () { const emailRenderer = new EmailRenderer({ urlUtils: { urlFor() { return 'http://example.com/subdirectory'; } } }); const response = await emailRenderer.createUnsubscribeUrl(); assert.equal(response, `http://example.com/subdirectory/unsubscribe/?preview=1`); }); }); describe('limitImageWidth', function () { it('Limits width of local images', async function () { const emailRenderer = new EmailRenderer({ imageSize: { getImageSizeFromUrl() { return { width: 2000, height: 1000 }; } }, storageUtils: { isLocalImage(url) { return url === 'http://your-blog.com/content/images/2017/01/02/example.png'; } } }); const response = await emailRenderer.limitImageWidth('http://your-blog.com/content/images/2017/01/02/example.png'); assert.equal(response.width, 600); assert.equal(response.height, 300); assert.equal(response.href, 'http://your-blog.com/content/images/size/w1200/2017/01/02/example.png'); }); it('Limits width and height of local images', async function () { const emailRenderer = new EmailRenderer({ imageSize: { getImageSizeFromUrl() { return { width: 2000, height: 1000 }; } }, storageUtils: { isLocalImage(url) { return url === 'http://your-blog.com/content/images/2017/01/02/example.png'; } } }); const response = await emailRenderer.limitImageWidth('http://your-blog.com/content/images/2017/01/02/example.png', 600, 600); assert.equal(response.width, 600); assert.equal(response.height, 600); assert.equal(response.href, 'http://your-blog.com/content/images/size/w1200h1200/2017/01/02/example.png'); }); it('Ignores and logs errors', async function () { const emailRenderer = new EmailRenderer({ imageSize: { getImageSizeFromUrl() { throw new Error('Oops, this is a test.'); } }, storageUtils: { isLocalImage(url) { return url === 'http://your-blog.com/content/images/2017/01/02/example.png'; } } }); const response = await emailRenderer.limitImageWidth('http://your-blog.com/content/images/2017/01/02/example.png'); assert.equal(response.width, 0); assert.equal(response.href, 'http://your-blog.com/content/images/2017/01/02/example.png'); sinon.assert.calledOnce(logStub); }); it('Limits width of unsplash images', async function () { const emailRenderer = new EmailRenderer({ imageSize: { getImageSizeFromUrl() { return { width: 2000 }; } }, storageUtils: { isLocalImage(url) { return url === 'http://your-blog.com/content/images/2017/01/02/example.png'; } } }); const response = await emailRenderer.limitImageWidth('https://images.unsplash.com/photo-1657816793628-191deb91e20f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjU3ODkzNjU5&ixlib=rb-1.2.1&q=80&w=2000'); assert.equal(response.width, 600); assert.equal(response.height, null); assert.equal(response.href, 'https://images.unsplash.com/photo-1657816793628-191deb91e20f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjU3ODkzNjU5&ixlib=rb-1.2.1&q=80&w=1200'); }); it('Limits width and height of unsplash images', async function () { const emailRenderer = new EmailRenderer({ imageSize: { getImageSizeFromUrl() { return { width: 2000, height: 1000 }; } }, storageUtils: { isLocalImage(url) { return url === 'http://your-blog.com/content/images/2017/01/02/example.png'; } } }); const response = await emailRenderer.limitImageWidth('https://images.unsplash.com/photo-1657816793628-191deb91e20f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjU3ODkzNjU5&ixlib=rb-1.2.1&q=80&w=2000', 600, 600); assert.equal(response.width, 600); assert.equal(response.height, 600); assert.equal(response.href, 'https://images.unsplash.com/photo-1657816793628-191deb91e20f?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjU3ODkzNjU5&ixlib=rb-1.2.1&q=80&w=1200&h=1200'); }); it('Does not increase width of images', async function () { const emailRenderer = new EmailRenderer({ imageSize: { getImageSizeFromUrl() { return { width: 300 }; } }, storageUtils: { isLocalImage(url) { return url === 'http://your-blog.com/content/images/2017/01/02/example.png'; } } }); const response = await emailRenderer.limitImageWidth('https://example.com/image.png'); assert.equal(response.width, 300); assert.equal(response.href, 'https://example.com/image.png'); }); }); });