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.
This commit is contained in:
Simon Backx 2023-10-24 12:35:47 +02:00 committed by GitHub
parent 88d42f204c
commit c8f71e8504
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 96 additions and 17 deletions

View File

@ -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 = () => {

View File

@ -41,7 +41,8 @@ const ALPHA_FEATURES = [
'tipsAndDonations',
'importMemberTier',
'recommendations',
'lexicalIndicators'
'lexicalIndicators',
'listUnsubscribeHeader'
];
module.exports.GA_KEYS = [...GA_FEATURES];

View File

@ -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 = /^(?<recipientProperty>\w+?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?$/;
@ -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) {

View File

@ -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');

View File

@ -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;
}