diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap index 14d83f70fe..e7fad23783 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap @@ -416,6 +416,7 @@ table.body figcaption a { + @@ -554,6 +555,7 @@ Another email card with a similar replacement, Jamie + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid] @@ -583,7 +585,7 @@ exports[`Email Preview API Read can read post email preview with email card and Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18958", + "content-length": "18962", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -917,6 +919,7 @@ table.body figcaption a { +
@@ -1071,6 +1074,7 @@ Header Level 3 + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid] @@ -1100,7 +1104,7 @@ exports[`Email Preview API Read can read post email preview with fields 4: [head Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "23781", + "content-length": "23785", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -1461,6 +1465,7 @@ table.body figcaption a { +
@@ -1593,6 +1598,7 @@ Testing links [https://ghost.org] in email excerpt and apostrophes ' + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid] @@ -1635,7 +1641,7 @@ exports[`Email Preview API Read has custom content transformations for email com Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18724", + "content-length": "18728", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -2330,6 +2336,7 @@ table.body figcaption a { +
@@ -2467,6 +2474,7 @@ Testing links [https://ghost.org] in email excerpt and apostrophes ' + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid] @@ -2509,7 +2517,7 @@ exports[`Email Preview API Read uses the newsletter provided through ?newsletter Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "19216", + "content-length": "19220", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -3230,6 +3238,7 @@ table.body figcaption a { +
@@ -3367,6 +3376,7 @@ Testing links [https://ghost.org] in email excerpt and apostrophes ' + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid] @@ -3409,7 +3419,7 @@ exports[`Email Preview API Read uses the posts newsletter by default 4: [headers Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "19216", + "content-length": "19220", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap b/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap index 077fe2190e..cb20dbb437 100644 --- a/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap +++ b/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap @@ -322,6 +322,7 @@ table.body figcaption a { +
@@ -450,6 +451,7 @@ Testing feature image caption + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&newsletter=requested-newsletter-uuid] @@ -775,6 +777,7 @@ table.body figcaption a { +
@@ -879,6 +882,7 @@ Hello world + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&newsletter=requested-newsletter-uuid] @@ -1218,6 +1222,7 @@ table.body figcaption a { +
@@ -1342,6 +1347,7 @@ Hello world + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&newsletter=requested-newsletter-uuid] @@ -1681,6 +1687,7 @@ table.body figcaption a { +
@@ -1807,6 +1814,7 @@ Hey Simon, Hey Simon, + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&newsletter=requested-newsletter-uuid] @@ -2146,6 +2154,7 @@ table.body figcaption a { +
@@ -2272,6 +2281,7 @@ Hey there, Hey , + Ghost © 2023 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&newsletter=requested-newsletter-uuid] diff --git a/ghost/email-service/lib/batch-sending-service.js b/ghost/email-service/lib/batch-sending-service.js index 59b322e09b..5a1d34b02a 100644 --- a/ghost/email-service/lib/batch-sending-service.js +++ b/ghost/email-service/lib/batch-sending-service.js @@ -497,13 +497,14 @@ class BatchSendingService { * @returns {Promise} */ async getBatchMembers(batchId) { - const models = await this.#models.EmailRecipient.findAll({filter: `batch_id:${batchId}`}); + const models = await this.#models.EmailRecipient.findAll({filter: `batch_id:${batchId}`, withRelated: ['member']}); return models.map((model) => { return { id: model.get('member_id'), uuid: model.get('member_uuid'), email: model.get('member_email'), - name: model.get('member_name') + name: model.get('member_name'), + createdAt: model.related('member')?.get('created_at') ?? null }; }); } diff --git a/ghost/email-service/lib/email-renderer.js b/ghost/email-service/lib/email-renderer.js index e047f788b6..93de1f2b42 100644 --- a/ghost/email-service/lib/email-renderer.js +++ b/ghost/email-service/lib/email-renderer.js @@ -20,6 +20,7 @@ const htmlToPlaintext = require('@tryghost/html-to-plaintext'); * @prop {string} uuid * @prop {string} email * @prop {string} name + * @prop {Date|null} createdAt This can be null if the member has been deleted for older email recipient rows */ /** @@ -372,6 +373,29 @@ class EmailRenderer { getValue: (member) => { return member.name?.split(' ')[0]; } + }, + { + id: 'name', + getValue: (member) => { + return member.name; + } + }, + { + id: 'email', + getValue: (member) => { + return member.email; + } + }, + { + id: 'created_at', + getValue: (member) => { + const timezone = this.#settingsCache.get('timezone'); + return member.createdAt ? DateTime.fromJSDate(member.createdAt).setZone(timezone).setLocale('en-gb').toLocaleString({ + year: 'numeric', + month: 'long', + day: 'numeric' + }) : ''; + } } ]; @@ -593,7 +617,8 @@ class EmailRenderer { newsletter: { name: newsletter.get('name'), showPostTitleSection: newsletter.get('show_post_title_section'), - showCommentCta: newsletter.get('show_comment_cta') && this.#settingsCache.get('comments_enabled') !== 'off' && this.#labs.isSet('makingItRain') + showCommentCta: newsletter.get('show_comment_cta') && this.#settingsCache.get('comments_enabled') !== 'off' && this.#labs.isSet('makingItRain'), + showSubscriptionDetails: newsletter.get('show_subscription_details') && this.#labs.isSet('makingItRain') }, //CSS diff --git a/ghost/email-service/lib/email-service.js b/ghost/email-service/lib/email-service.js index 66ec80963e..5cdbcdec43 100644 --- a/ghost/email-service/lib/email-service.js +++ b/ghost/email-service/lib/email-service.js @@ -188,7 +188,8 @@ class EmailService { id: 'example-id', uuid: 'example-uuid', email: 'jamie@example.com', - name: 'Jamie Larson' + name: 'Jamie Larson', + createdAt: new Date() }; } @@ -211,6 +212,7 @@ class EmailService { exampleMember.uuid = member.get('uuid'); exampleMember.email = member.get('email'); exampleMember.name = member.get('name'); + exampleMember.createdAt = member.get('created_at'); } else { exampleMember.name = ''; // Force empty name to simulate name fallbacks exampleMember.email = email; diff --git a/ghost/email-service/lib/email-templates/template.hbs b/ghost/email-service/lib/email-templates/template.hbs index 25b45afe2d..918f552245 100644 --- a/ghost/email-service/lib/email-templates/template.hbs +++ b/ghost/email-service/lib/email-templates/template.hbs @@ -151,6 +151,19 @@ {{/if}} + {{#if newsletter.showSubscriptionDetails}} + + + + {{/if}} +
+

Subscription details

+

You are receiving this because you are a paidfree subscriber to {{site.title}}.

+

Name: %%{name}%%

+

Email: %%{email}%%

+

Member since: %%{created_at}%%

+

Renewal date: todo

+
diff --git a/ghost/email-service/test/batch-sending-service.test.js b/ghost/email-service/test/batch-sending-service.test.js index 8e2f5a8f9e..0ae2a65774 100644 --- a/ghost/email-service/test/batch-sending-service.test.js +++ b/ghost/email-service/test/batch-sending-service.test.js @@ -584,18 +584,26 @@ describe('Batch Sending Service', function () { beforeEach(function () { EmailRecipient = createModelClass({ findAll: [ - createModel({ + { member_id: '123', member_uuid: '123', member_email: 'example@example.com', - member_name: 'Test User' - }), - createModel({ + member_name: 'Test User', + loaded: ['member'], + member: createModel({ + created_at: new Date() + }) + }, + { member_id: '124', member_uuid: '124', member_email: 'example2@example.com', - member_name: 'Test User 2' - }) + member_name: 'Test User 2', + loaded: ['member'], + member: createModel({ + created_at: new Date() + }) + } ] }); }); @@ -912,6 +920,31 @@ describe('Batch Sending Service', function () { }); }); + describe('getBatchMembers', function () { + it('Works for recipients without members', async function () { + const EmailRecipient = createModelClass({ + findAll: [ + { + member_id: '123', + member_uuid: '123', + member_email: 'example@example.com', + member_name: 'Test User', + loaded: ['member'], + member: null + } + ] + }); + + const service = new BatchSendingService({ + models: {EmailRecipient} + }); + + const result = await service.getBatchMembers('id123'); + assert.equal(result.length, 1); + assert.equal(result[0].createdAt, null); + }); + }); + describe('retryDb', function () { it('Does retry', async function () { const service = new BatchSendingService({}); diff --git a/ghost/email-service/test/email-renderer.test.js b/ghost/email-service/test/email-renderer.test.js index 822de11e50..2d7939ca10 100644 --- a/ghost/email-service/test/email-renderer.test.js +++ b/ghost/email-service/test/email-renderer.test.js @@ -83,6 +83,13 @@ describe('Email renderer', function () { }, labs: { isSet: () => true + }, + settingsCache: { + get: (key) => { + if (key === 'timezone') { + return 'UTC'; + } + } } }); newsletter = createModel({ @@ -92,7 +99,8 @@ describe('Email renderer', function () { id: '456', uuid: 'myuuid', name: 'Test User', - email: 'test@example.com' + email: 'test@example.com', + createdAt: new Date(2023, 2, 13, 12, 0) }; }); @@ -138,6 +146,43 @@ describe('Email renderer', function () { 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 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 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')}); @@ -1119,6 +1164,60 @@ describe('Email renderer', function () { 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); + }); }); describe('createUnsubscribeUrl', function () {