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:
parent
88d42f204c
commit
c8f71e8504
@ -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 = () => {
|
||||
|
@ -41,7 +41,8 @@ const ALPHA_FEATURES = [
|
||||
'tipsAndDonations',
|
||||
'importMemberTier',
|
||||
'recommendations',
|
||||
'lexicalIndicators'
|
||||
'lexicalIndicators',
|
||||
'listUnsubscribeHeader'
|
||||
];
|
||||
|
||||
module.exports.GA_KEYS = [...GA_FEATURES];
|
||||
|
@ -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+?)(?:,? *(?:"|")(?<fallback>.*?)(?:"|"))?$/;
|
||||
@ -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) {
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user