const {EmailRenderer} = require('../'); const assert = require('assert/strict'); 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 crypto = require('crypto'); async 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 = await 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')); } const getMembersValidationKey = () => { return 'members-key'; }; 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; let labsEnabled = false; beforeEach(function () { labsEnabled = false; emailRenderer = new EmailRenderer({ urlUtils: { urlFor: () => 'http://example.com/subdirectory/' }, labs: { isSet: () => labsEnabled }, settingsCache: { get: (key) => { if (key === 'timezone') { return 'UTC'; } } }, settingsHelpers: {getMembersValidationKey} }); 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 the unsubscribe header replacement by default', function () { const html = 'Hello world'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 1); assert.equal(replacements[0].token.toString(), '/%%\\{list_unsubscribe\\}%%/g'); assert.equal(replacements[0].id, 'list_unsubscribe'); const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update(member.uuid).digest('hex'); assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=${member.uuid}&key=${memberHmac}&newsletter=newsletteruuid`); }); 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, 2); 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, 2); 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, 2); 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, 2); assert.equal(replacements[0].token.toString(), '/%%\\{unsubscribe_url\\}%%/g'); assert.equal(replacements[0].id, 'unsubscribe_url'); const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update(member.uuid).digest('hex'); assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=${member.uuid}&key=${memberHmac}&newsletter=newsletteruuid`); }); it('returns correct name', function () { const html = 'Hello %%{name}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 2); 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, 2); 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, 2); 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, 2); 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, 2); 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, 2); assert.equal(replacements[0].token.toString(), '/%%\\{status\\}%%/g'); assert.equal(replacements[0].id, 'status'); assert.equal(replacements[0].getValue(member), 'complimentary'); }); it('returns mapped trialing status', function () { member.status = 'paid'; member.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 } ]; const html = 'Hello %%{status}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 2); assert.equal(replacements[0].token.toString(), '/%%\\{status\\}%%/g'); assert.equal(replacements[0].id, 'status'); assert.equal(replacements[0].getValue(member), 'trialing'); }); 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, 2); 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}%%,'; member.status = 'paid'; member.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 } ]; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 2); assert.equal(replacements[0].token.toString(), '/%%\\{status_text\\}%%/g'); assert.equal(replacements[0].id, 'status_text'); assert.equal(replacements[0].getValue(member), '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 correct createdAt', function () { const html = 'Hello %%{created_at}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 2); 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, 2); 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, 2); 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, 4); 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: ''}), ''); }); it('handles members uuid and key', function () { const html = '%%{uuid}%% %%{key}%%'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); assert.equal(replacements.length, 3); assert.equal(replacements[0].token.toString(), '/%%\\{uuid\\}%%/g'); assert.equal(replacements[0].id, 'uuid'); assert.equal(replacements[0].getValue(member), 'myuuid'); assert.equal(replacements[1].token.toString(), '/%%\\{key\\}%%/g'); assert.equal(replacements[1].id, 'key'); const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update(member.uuid).digest('hex'); assert.equal(replacements[1].getValue(member), memberHmac); }); }); describe('isMemberTrialing', function () { let emailRenderer; beforeEach(function () { emailRenderer = new EmailRenderer({ urlUtils: { urlFor: () => 'http://example.com/subdirectory/' }, labs: { isSet: () => false }, settingsCache: { get: (key) => { if (key === 'timezone') { return 'UTC'; } } } }); }); it('Returns false 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.isMemberTrialing(member); assert.equal(result, false); }); it('Returns false for paid member without trial', 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.isMemberTrialing(member); assert.equal(result, false); }); it('Returns true 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.isMemberTrialing(member); assert.equal(result, true); }); it('Returns false for expired 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(2000, 2, 13, 12, 0), current_period_end: new Date(2023, 2, 13, 12, 0), cancel_at_period_end: false } ], tiers: [] }; const result = emailRenderer.isMemberTrialing(member); assert.equal(result, false); }); }); describe('getMemberStatusText', function () { let emailRenderer; beforeEach(function () { emailRenderer = new EmailRenderer({ urlUtils: { urlFor: () => 'http://example.com/subdirectory/' }, labs: { isSet: () => false }, 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, ''); }); 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: () => false } }); 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'); }); it('adds [TEST] prefix for test emails', function () { const post = createModel({ posts_meta: createModel({ email_subject: '' }), title: 'Sample Post', loaded: ['posts_meta'] }); let response = emailRenderer.getSubject(post, true); response.should.equal('[TEST] 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: () => false }, emailAddressService: { getAddress(addresses) { return addresses; } } }); 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 emailAddressService = { getAddress(addresses) { return addresses; }, managedEmailEnabled: true }; 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: () => false }, emailAddressService }); 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('[legacy] returns correct reply to address for newsletter', function () { emailAddressService.managedEmailEnabled = false; const newsletter = createModel({ sender_email: 'ghost@example.com', sender_name: 'Ghost', sender_reply_to: 'newsletter' }); const response = emailRenderer.getReplyToAddress({}, newsletter); assert.equal(response, `"Ghost" `); emailAddressService.managedEmailEnabled = true; }); it('returns null when set to newsletter', function () { emailAddressService.managedEmailEnabled = true; const newsletter = createModel({ sender_email: 'ghost@example.com', sender_name: 'Ghost', sender_reply_to: 'newsletter' }); const response = emailRenderer.getReplyToAddress({}, newsletter); assert.equal(response, null); }); it('returns correct custom reply to address', function () { const newsletter = createModel({ sender_email: 'ghost@example.com', sender_name: 'Ghost', sender_reply_to: 'anything@iwant.com' }); const response = emailRenderer.getReplyToAddress({}, newsletter); assert.equal(response, 'anything@iwant.com'); }); it('handles removed replyto addresses', function () { const newsletter = createModel({ sender_email: 'ghost@example.com', sender_name: 'Ghost', sender_reply_to: 'anything@iwant.com' }); emailAddressService.getAddress = ({from}) => { return { from }; }; const response = emailRenderer.getReplyToAddress({}, newsletter); assert.equal(response, null); }); }); 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: () => false } }); it('returns correct empty segment for post', async function () { let post = { get: (key) => { if (key === 'lexical') { return '{}'; } } }; let response = await emailRenderer.getSegments(post); response.should.eql([null]); post = { get: (key) => { if (key === 'mobiledoc') { return '{}'; } } }; response = await emailRenderer.getSegments(post); response.should.eql([null]); }); it('returns correct segments for post with members only card', async function () { emailRenderer = new EmailRenderer({ renderers: { lexical: { render: () => { return '

Lexical Test members only section

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

Lexical Test

'; labsEnabled = true; // TODO: odd default because it means we're testing the unused email-customization template 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 = {}; addTrackingToUrlStub = sinon.stub(); addTrackingToUrlStub.callsFake((u, _post, uuid) => { return new URL('http://tracked-link.com/?m=' + encodeURIComponent(uuid) + '&url=' + encodeURIComponent(u.href)); }); emailRenderer = new EmailRenderer({ audienceFeedbackService: { buildLink: (_uuid, _postId, score, key) => { return new URL('http://feedback-link.com/?score=' + encodeURIComponent(score) + '&uuid=' + encodeURIComponent(_uuid) + '&key=' + encodeURIComponent(key)); } }, 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: addTrackingToUrlStub } }, outboundLinkTagger: { addToUrl: (u, newsletter) => { u.searchParams.append('source_tracking', newsletter?.get('name') ?? 'site'); return u; } }, labs: { isSet: (key) => { if (typeof labsEnabled === 'object') { return labsEnabled[key] || false; } return labsEnabled; } } }); }); it('Renders with labs disabled', async function () { labsEnabled = false; 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 = {}; await emailRenderer.renderBody( post, newsletter, segment, options ); }); it('returns feedback buttons and unsubscribe 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(4); response.replacements.should.match([ { id: 'uuid' }, { id: 'key' }, { id: 'unsubscribe_url' }, { id: 'list_unsubscribe' } ]); 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('does not include members-only content in preheader for non-members', async function () { renderedPost = '
Lexical Test
some text for both finishing part only for members'; let post = { related: sinon.stub(), get: (key) => { if (key === 'lexical') { return '{}'; } if (key === 'visibility') { return 'paid'; } if (key === 'plaintext') { return 'foobarbaz'; } }, getLazyRelation: sinon.stub() }; let newsletter = { get: sinon.stub() }; let response = await emailRenderer.renderBody( post, newsletter, 'status:free', {} ); const $ = cheerio.load(response.html); should($('.preheader').text()).eql('Lexical Test some text for both'); }); it('does not include paid segmented content in preheader for non-paying members', async function () { renderedPost = '
Lexical Test
members only section
some text for both'; let post = { related: sinon.stub(), get: (key) => { if (key === 'lexical') { return '{}'; } if (key === 'visibility') { return 'public'; } if (key === 'plaintext') { return 'foobarbaz'; } }, getLazyRelation: sinon.stub() }; let newsletter = { get: sinon.stub() }; let response = await emailRenderer.renderBody( post, newsletter, 'status:free', {} ); const $ = cheerio.load(response.html); should($('.preheader').text()).eql('Lexical Test some text for both'); }); 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('works in dark mode', 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, background_color: '#000000' }); const segment = null; const options = {}; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); assert.doesNotMatch(response.html, /is-light-background/); }); it('works in light mode', 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, background_color: '#FFFFFF' }); const segment = null; const options = {}; let response = await emailRenderer.renderBody( post, newsletter, segment, options ); assert.doesNotMatch(response.html, /is-dark-background/); }); it('replaces all links except the unsubscribe, feedback and powered by Ghost 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

HelloHelloIgnore me

'; 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 === '#') { continue; } if (href.includes('unsubscribe_url')) { href.should.eql('%%{unsubscribe_url}%%'); } else if (href.includes('feedback-link.com')) { href.should.containEql('%%{uuid}%%'); } else if (href.includes('https://ghost.org/?via=pbg-newsletter')) { href.should.not.containEql('tracked-link.com'); } 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=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%2Fencoded-link.com%2F%3Fcode%3Dtest%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}%%&key=%%{key}%%`, `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`, `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`, `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`, `%%{unsubscribe_url}%%`, `https://ghost.org/?via=pbg-newsletter&source_tracking=site` ]); // Check uuid in replacements response.replacements.length.should.eql(4); response.replacements[0].id.should.eql('uuid'); response.replacements[0].token.should.eql(/%%\{uuid\}%%/g); response.replacements[1].id.should.eql('key'); response.replacements[1].token.should.eql(/%%\{key\}%%/g); response.replacements[2].id.should.eql('unsubscribe_url'); response.replacements[2].token.should.eql(/%%\{unsubscribe_url\}%%/g); response.replacements[3].id.should.eql('list_unsubscribe'); }); it('replaces all relative links if click tracking is disabled', 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: false }; renderedPost = '

Lexical Test

HelloIgnore me

'; 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); } // 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://example.com/', 'http://example.com/', 'http://example.com/', 'http://example.com/#relative-test', '#', 'http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%', 'http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%', 'http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%', 'http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%', '%%{unsubscribe_url}%%', 'https://ghost.org/?via=pbg-newsletter' ]); }); it('handles encoded 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 if (href.includes('https://ghost.org/?via=pbg-newsletter')) { href.should.not.containEql('tracked-link.com'); } 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=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}%%&key=%%{key}%%`, `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`, `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`, `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`, `%%{unsubscribe_url}%%`, `https://ghost.org/?via=pbg-newsletter&source_tracking=site` ]); // Check uuid in replacements response.replacements.length.should.eql(4); response.replacements[0].id.should.eql('uuid'); response.replacements[0].token.should.eql(/%%\{uuid\}%%/g); response.replacements[1].id.should.eql('key'); response.replacements[1].token.should.eql(/%%\{key\}%%/g); response.replacements[2].id.should.eql('unsubscribe_url'); response.replacements[2].token.should.eql(/%%\{unsubscribe_url\}%%/g); response.replacements[3].id.should.eql('list_unsubscribe'); }); 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; } if (key === 'show_post_title_section') { 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'); // Check contains the post name twice assert.equal(response.html.match(/Test Post/g).length, 3, 'Should contain the post name 3 times: in the title element, the preheader and in the post title section'); response.html.should.containEql('Unsubscribe'); response.html.should.containEql('http://example.com'); response.replacements.length.should.eql(4); response.replacements.should.match([ { id: 'uuid' }, { id: 'key' }, { id: 'unsubscribe_url' }, { id: 'list_unsubscribe' } ]); 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 ); await 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'); }); it('does not replace img height and width with auto from css', async function () { const post = createModel(basePost); 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 = {}; renderedPost = '

This is the post.

Theres an image!

'; const response = await emailRenderer.renderBody( post, newsletter, segment, options ); // console.log(response.html); assert.equal(response.html.includes('width="248" height="248"'), true, 'Should not replace img height and width with auto from css'); assert.equal(response.html.includes('width="auto" height="auto"'), false, 'Should not replace img height and width with auto from css'); }); describe('show excerpt', function () { it('is rendered when enabled and customExcerpt is present', async function () { const post = createModel(Object.assign({}, basePost, {custom_excerpt: 'This is an excerpt'})); const newsletter = createModel({ show_post_title_section: true, show_excerpt: true }); const segment = null; const options = {}; const response = await emailRenderer.renderBody( post, newsletter, segment, options ); await validateHtml(response.html); assert.equal(response.html.match(/This is an excerpt/g).length, 2, 'Excerpt should appear twice (preheader and excerpt section)'); }); it('is not rendered when disabled and customExcerpt is present', async function () { const post = createModel(Object.assign({}, basePost, {custom_excerpt: 'This is an excerpt'})); const newsletter = createModel({ show_post_title_section: true, show_excerpt: false }); const segment = null; const options = {}; const response = await emailRenderer.renderBody( post, newsletter, segment, options ); await validateHtml(response.html); assert.equal(response.html.match(/This is an excerpt/g).length, 1, 'Subtitle should only appear once (preheader, excerpt section skipped)'); response.html.should.not.containEql('post-excerpt-wrapper'); }); it('does not render when enabled and customExcerpt is not present', async function () { const post = createModel(Object.assign({}, basePost, {custom_excerpt: null})); const newsletter = createModel({ show_post_title_section: true, show_excerpt: true }); const segment = null; const options = {}; const response = await emailRenderer.renderBody( post, newsletter, segment, options ); await validateHtml(response.html); response.html.should.not.containEql('post-excerpt-wrapper'); }); }); }); describe('getTemplateData', function () { let settings = {}; let labsEnabled = true; let emailRenderer; beforeEach(function () { settings = { timezone: 'Etc/UTC' }; 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'), custom_excerpt: 'Super long custom excerpt. Super long custom excerpt. Super long custom excerpt. Super long custom excerpt. Super long custom excerpt.', feature_image: 'http://example.com/image.jpg' }, { title: 'Test Post 2', published_at: new Date('2018-01-01T00:00:00.000Z'), feature_image: null, plaintext: '' }, { title: 'Test Post 3', published_at: null, // required for full test coverage feature_image: null, plaintext: 'Nothing special.' } ] }) } }); }); async function templateDataWithSettings(settingsObj) { const html = ''; const post = createModel({ posts_meta: createModel({}), loaded: ['posts_meta'] }); const newsletter = createModel({ ...settingsObj }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); return data; } it('Uses the correct background colors based on settings', async function () { const tests = [ {input: 'Invalid Color', expected: '#ffffff'}, {input: '#BADA55', expected: '#BADA55'}, {input: 'dark', expected: '#15212a'}, {input: 'light', expected: '#ffffff'}, {input: null, expected: '#ffffff'} ]; for (const test of tests) { const data = await templateDataWithSettings({ background_color: test.input }); assert.equal(data.backgroundColor, test.expected); } }); it('Uses the correct border colors based on settings', async function () { settings.accent_color = '#ABC123'; const tests = [ {input: 'Invalid Color', expected: null}, {input: '#BADA55', expected: '#BADA55'}, {input: 'auto', expected: '#FFFFFF', background_color: '#15212A'}, {input: 'auto', expected: '#000000', background_color: '#ffffff'}, {input: 'light', expected: null}, {input: 'accent', expected: settings.accent_color}, {input: 'transparent', expected: null} ]; for (const test of tests) { const data = await templateDataWithSettings({ border_color: test.input, background_color: test.background_color }); assert.equal(data.borderColor, test.expected); } }); it('Uses the correct title colors based on settings and background color', async function () { settings.accent_color = '#DEF456'; const tests = [ {input: '#BADA55', expected: '#BADA55'}, {input: 'accent', expected: settings.accent_color}, {input: 'Invalid Color', expected: '#FFFFFF', background_color: '#15212A'}, {input: null, expected: '#000000', background_color: '#ffffff'} ]; for (const test of tests) { const data = await templateDataWithSettings({ title_color: test.input, background_color: test.background_color }); assert.equal(data.titleColor, test.expected); } }); it('Sets the backgroundIsDark correctly', async function () { const tests = [ {background_color: '#15212A', expected: true}, {background_color: '#ffffff', expected: false} ]; for (const test of tests) { const data = await templateDataWithSettings({ background_color: test.background_color }); assert.equal(data.backgroundIsDark, test.expected); } }); it('Sets the linkColor correctly', async function () { settings.accent_color = '#A1B2C3'; const tests = [ {background_color: '#15212A', expected: '#ffffff'}, {background_color: '#ffffff', expected: settings.accent_color} ]; for (const test of tests) { const data = await templateDataWithSettings({ background_color: test.background_color }); assert.equal(data.linkColor, test.expected); } }); 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-no-excerpt post-title-serif post-title-left', titleLink: 'post-title-link post-title-link-left', meta: 'post-meta post-meta-left', excerpt: 'post-excerpt post-excerpt-no-feature-image post-excerpt-serif-sans post-excerpt-left', body: 'post-content-sans-serif' }); }); it('has correct excerpt classes for serif title+body', 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: 'serif', show_excerpt: true }); const data = await emailRenderer.getTemplateData({post, newsletter, html, addPaywall: false}); assert.equal(data.classes.excerpt, 'post-excerpt post-excerpt-no-feature-image post-excerpt-serif-serif post-excerpt-left'); }); 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 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 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, [ { excerpt: 'Super long custom excerpt. Super long custom excerpt. Super long custom excerpt. Super long custom excerpt. Super long …', title: 'Test Post 1', url: 'http://example.com', featureImage: { src: 'http://example.com/image.jpg', width: 0, height: null }, featureImageMobile: { src: 'http://example.com/image.jpg', width: 0, height: null } }, { featureImage: null, featureImageMobile: null, excerpt: '', title: 'Test Post 2', url: 'http://example.com' }, { featureImage: null, featureImageMobile: null, excerpt: 'Nothing special.', title: 'Test Post 3', url: 'http://example.com' } ]); }); }); describe('createUnsubscribeUrl', function () { let emailRenderer; beforeEach(function () { emailRenderer = new EmailRenderer({ urlUtils: { urlFor() { return 'http://example.com/subdirectory'; } }, settingsHelpers: { getMembersValidationKey } }); }); it('includes member uuid and newsletter id', async function () { const response = await emailRenderer.createUnsubscribeUrl('memberuuid', { newsletterUuid: 'newsletteruuid' }); const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update('memberuuid').digest('hex'); assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&key=${memberHmac}&newsletter=newsletteruuid`); }); it('includes comments', async function () { const response = await emailRenderer.createUnsubscribeUrl('memberuuid', { comments: true }); const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update('memberuuid').digest('hex'); assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&key=${memberHmac}&comments=1`); }); it('works for previews', async function () { const response = await emailRenderer.createUnsubscribeUrl(); assert.equal(response, `http://example.com/subdirectory/unsubscribe/?preview=1`); }); }); describe('truncateText', function () { it('works for null', async function () { const emailRenderer = new EmailRenderer({}); assert.equal(emailRenderer.truncateText(null, 100), ''); }); }); describe('truncateHTML', function () { it('works correctly', async function () { const emailRenderer = new EmailRenderer({}); assert.equal(emailRenderer.truncateHtml('This is a short one', 10, 5), 'This is a…'); assert.equal(emailRenderer.truncateHtml('This is a', 10, 5), 'This is a'); assert.equal(emailRenderer.truncateHtml('This', 10, 5), 'This'); assert.equal(emailRenderer.truncateHtml('This is a long text', 5, 5), 'This…'); assert.equal(emailRenderer.truncateHtml('This is a long text', 5), 'This…'); assert.equal(emailRenderer.truncateHtml(null, 10, 5), ''); }); }); 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'); }); }); });