From c8f71e850412660b7b47c5ca5e17dfbd5fdb2993 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Tue, 24 Oct 2023 12:35:47 +0200 Subject: [PATCH] Added list-unsubscribe feature flag and header (#18736) refs https://github.com/TryGhost/Product/issues/4053 This adds the feature flag. If enabled, the list-unsubscribe header should be set. The value currently is only for testing purposes and probably won't work yet. --- .../settings/advanced/labs/AlphaFeatures.tsx | 4 ++ ghost/core/core/shared/labs.js | 3 +- ghost/email-service/lib/EmailRenderer.js | 47 +++++++++++++++++++ .../email-service/test/email-renderer.test.js | 46 +++++++++++++----- ghost/mailgun-client/lib/MailgunClient.js | 13 +++-- 5 files changed, 96 insertions(+), 17 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx index fef58eb0e4..749c0c9bcc 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx @@ -51,6 +51,10 @@ const features = [{ title: 'Recommendations', description: 'Enables publishers to recommend sites to their audience', flag: 'recommendations' +},{ + title: 'List-Unsubscribe header', + description: 'Set the List-Unsubscribe header in emails', + flag: 'listUnsubscribeHeader' }]; const AlphaFeatures: React.FC = () => { diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index dd7daca2d3..ddb5723321 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -41,7 +41,8 @@ const ALPHA_FEATURES = [ 'tipsAndDonations', 'importMemberTier', 'recommendations', - 'lexicalIndicators' + 'lexicalIndicators', + 'listUnsubscribeHeader' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/email-service/lib/EmailRenderer.js b/ghost/email-service/lib/EmailRenderer.js index 5c22ef0972..6223c99e45 100644 --- a/ghost/email-service/lib/EmailRenderer.js +++ b/ghost/email-service/lib/EmailRenderer.js @@ -449,6 +449,29 @@ class EmailRenderer { return unsubscribeUrl.href; } + /** + * This is identical to createUnsubscribeUrl, but used in the List-Unsubscribe header, and uses the POST method + * to unsubscribe automatically + */ + createPostUnsubscribeUrl(uuid, options = {}) { + const siteUrl = 'https://ghost.org'; + const unsubscribeUrl = new URL(siteUrl); + unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/'); + if (uuid) { + unsubscribeUrl.searchParams.set('uuid', uuid); + } //else { + // unsubscribeUrl.searchParams.set('preview', '1'); + //} + if (options.newsletterUuid) { + unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid); + } + // if (options.comments) { + // unsubscribeUrl.searchParams.set('comments', '1'); + // } + + return unsubscribeUrl.href; + } + /** * createManageAccountUrl * @@ -622,6 +645,18 @@ class EmailRenderer { } ]; + if (this.#labs.isSet('listUnsubscribeHeader')) { + baseDefinitions.push( + { + id: 'list_unsubscribe', + getValue: (member) => { + return '<' + this.createPostUnsubscribeUrl(member.uuid, {newsletterUuid}) + '>'; + }, + required: true // Used in email headers + } + ); + } + // Now loop through all the definenitions to see which ones are actually used + to add fallbacks if needed const EMAIL_REPLACEMENT_REGEX = /%%\{(.*?)\}%%/g; const REPLACEMENT_STRING_REGEX = /^(?\w+?)(?:,? *(?:"|")(?.*?)(?:"|"))?$/; @@ -654,6 +689,18 @@ class EmailRenderer { } } + // Add all required replacements + for (const definition of baseDefinitions) { + if (definition.required && !replacements.find(r => r.id === definition.id)) { + replacements.push({ + id: definition.id, + originalId: definition.id, + token: new RegExp(`%%\\{${definition.id}\\}%%`, 'g'), + getValue: definition.getValue + }); + } + } + // Now loop any replacements with possible invalid characters and replace them with a clean id let counter = 1; for (const replacement of replacements) { diff --git a/ghost/email-service/test/email-renderer.test.js b/ghost/email-service/test/email-renderer.test.js index 374e262ff3..a5b04843ef 100644 --- a/ghost/email-service/test/email-renderer.test.js +++ b/ghost/email-service/test/email-renderer.test.js @@ -75,14 +75,16 @@ describe('Email renderer', 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: () => true + isSet: () => labsEnabled }, settingsCache: { get: (key) => { @@ -147,6 +149,16 @@ describe('Email renderer', function () { assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=myuuid&newsletter=newsletteruuid`); }); + it('returns correct list-unsubscribe value', function () { + labsEnabled = true; + const html = 'Hello'; + 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'); + assert.equal(replacements[0].getValue(member)[0], `<`); + }); + it('returns correct name', function () { const html = 'Hello %%{name}%%,'; const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')}); @@ -316,7 +328,7 @@ describe('Email renderer', function () { urlFor: () => 'http://example.com/subdirectory/' }, labs: { - isSet: () => true + isSet: () => false }, settingsCache: { get: (key) => { @@ -419,7 +431,7 @@ describe('Email renderer', function () { urlFor: () => 'http://example.com/subdirectory/' }, labs: { - isSet: () => true + isSet: () => false }, settingsCache: { get: (key) => { @@ -623,7 +635,7 @@ describe('Email renderer', function () { urlFor: () => 'http://example.com' }, labs: { - isSet: () => true + isSet: () => false } }); @@ -668,7 +680,7 @@ describe('Email renderer', function () { } }, labs: { - isSet: () => true + isSet: () => false } }); @@ -728,7 +740,7 @@ describe('Email renderer', function () { } }, labs: { - isSet: () => true + isSet: () => false } }); @@ -771,7 +783,7 @@ describe('Email renderer', function () { return 'http://example.com/post-id'; }, labs: { - isSet: () => true + isSet: () => false } }); @@ -810,7 +822,7 @@ describe('Email renderer', function () { return 'http://example.com/post-id'; }, labs: { - isSet: () => true + isSet: () => false } }); @@ -838,7 +850,7 @@ describe('Email renderer', function () { return 'http://example.com/post-id'; }, labs: { - isSet: () => true + isSet: () => false } }); @@ -1011,7 +1023,7 @@ describe('Email renderer', function () { // Unsubscribe button included response.plaintext.should.containEql('Unsubscribe [%%{unsubscribe_url}%%]'); response.html.should.containEql('Unsubscribe'); - response.replacements.length.should.eql(2); + response.replacements.length.should.eql(3); response.replacements.should.match([ { id: 'uuid' @@ -1019,6 +1031,9 @@ describe('Email renderer', function () { { id: 'unsubscribe_url', token: /%%\{unsubscribe_url\}%%/g + }, + { + id: 'list_unsubscribe' } ]); @@ -1371,11 +1386,12 @@ describe('Email renderer', function () { ]); // Check uuid in replacements - response.replacements.length.should.eql(2); + response.replacements.length.should.eql(3); 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); + response.replacements[2].id.should.eql('list_unsubscribe'); }); it('replaces all relative links if click tracking is disabled', async function () { @@ -1480,11 +1496,12 @@ describe('Email renderer', function () { ]); // Check uuid in replacements - response.replacements.length.should.eql(2); + response.replacements.length.should.eql(3); 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); + response.replacements[2].id.should.eql('list_unsubscribe'); }); it('removes data-gh-segment and renders paywall', async function () { @@ -1561,7 +1578,7 @@ describe('Email renderer', function () { response.html.should.containEql('Unsubscribe'); response.html.should.containEql('http://example.com'); - response.replacements.length.should.eql(2); + response.replacements.length.should.eql(3); response.replacements.should.match([ { id: 'uuid' @@ -1569,6 +1586,9 @@ describe('Email renderer', function () { { id: 'unsubscribe_url', token: /%%\{unsubscribe_url\}%%/g + }, + { + id: 'list_unsubscribe' } ]); response.html.should.not.containEql('members only section'); diff --git a/ghost/mailgun-client/lib/MailgunClient.js b/ghost/mailgun-client/lib/MailgunClient.js index bca196d876..a555687c70 100644 --- a/ghost/mailgun-client/lib/MailgunClient.js +++ b/ghost/mailgun-client/lib/MailgunClient.js @@ -26,8 +26,8 @@ module.exports = class MailgunClient { * { * 'test@example.com': { * name: 'Test User', - * unique_id: '12345abcde', - * unsubscribe_url: 'https://example.com/unsub/me' + * unsubscribe_url: 'https://example.com/unsub/me', + * list_unsubscribe: 'https://example.com/unsub/me' * } * } */ @@ -70,6 +70,13 @@ module.exports = class MailgunClient { 'recipient-variables': JSON.stringify(recipientData) }; + // Do we have a custom List-Unsubscribe header set? + // (we need a variable for this, as this is a per-email setting) + if (Object.keys(recipientData)[0] && recipientData[Object.keys(recipientData)[0]].list_unsubscribe) { + messageData['h:List-Unsubscribe'] = '%recipient.list_unsubscribe%'; + messageData['h:List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; + } + // add a reference to the original email record for easier mapping of mailgun event -> email if (message.id) { messageData['v:email-id'] = message.id; @@ -311,7 +318,7 @@ module.exports = class MailgunClient { * Returns configured batch size * * @returns {number} - */ + */ getBatchSize() { return this.#config.get('bulkEmail')?.batchSize ?? this.DEFAULT_BATCH_SIZE; }