Added List-Unsubscribe https endpoint (#18758)

refs TryGhost/Product#4052
This commit is contained in:
Simon Backx 2023-10-25 16:16:31 +02:00 committed by GitHub
parent 14104f8f74
commit 6cc19e1851
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 208 additions and 28 deletions

View File

@ -1,7 +1,9 @@
const debug = require('@tryghost/debug')('services:routing:controllers:unsubscribe');
const url = require('url');
const members = require('../../../../server/services/members');
const urlUtils = require('../../../../shared/url-utils');
const labs = require('../../../../shared/labs');
const logging = require('@tryghost/logging');
module.exports = async function unsubscribeController(req, res) {
debug('unsubscribeController');
@ -13,6 +15,44 @@ module.exports = async function unsubscribeController(req, res) {
return res.end('Email address not found.');
}
if (req.method === 'POST' && labs.isSet('listUnsubscribeHeader')) {
logging.info('[List-Unsubscribe] Received POST unsubscribe for ' + query.uuid + ', newsletter: ' + (query.newsletter ?? 'null') + ', comments: ' + (query.comments ?? 'false'));
// Do an actual unsubscribe
try {
const member = await members.api.members.get({uuid: query.uuid}, {withRelated: ['newsletters']});
if (member) {
if (query.comments) {
// Unsubscribe from comments
await members.api.members.update({
enable_comment_notifications: false
}, {
id: member.id
});
} else {
const filteredNewsletters = query.newsletter ?
member.related('newsletters').models
.filter(n => n.get('uuid') !== query.newsletter)
.map(n => ({id: n.id}))
: [];
await members.api.members.update({
newsletters: filteredNewsletters
}, {
id: member.id
});
}
}
} catch (e) {
logging.error({
err: e,
message: '[List-Unsubscribe] Failed POST unsubscribe for ' + query.uuid
});
return res.status(400).end();
}
return res.status(201).end();
}
const redirectUrl = new URL(urlUtils.urlFor('home', true));
redirectUrl.searchParams.append('uuid', query.uuid);
if (query.newsletter) {

View File

@ -9,8 +9,9 @@ const settingsCache = require('../../core/shared/settings-cache');
const DomainEvents = require('@tryghost/domain-events');
const {MemberPageViewEvent} = require('@tryghost/member-events');
const models = require('../../core/server/models');
const {mockManager} = require('../utils/e2e-framework');
const {mockManager, fixtureManager} = require('../utils/e2e-framework');
const DataGenerator = require('../utils/fixtures/data-generator');
const members = require('../../core/server/services/members');
function assertContentIsPresent(res) {
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
@ -20,6 +21,12 @@ function assertContentIsAbsent(res) {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
}
async function createMember(data) {
return await members.api.members.create({
...data
});
}
describe('Front-end members behavior', function () {
let request;
@ -249,6 +256,10 @@ describe('Front-end members behavior', function () {
});
describe('Unsubscribe', function () {
beforeEach(function () {
mockManager.mockLabsEnabled('listUnsubscribeHeader');
});
afterEach(function () {
mockManager.restore();
});
@ -269,6 +280,157 @@ describe('Front-end members behavior', function () {
await request.get('/unsubscribe/')
.expect(400);
});
it('should do an actual unsubscribe on POST', async function () {
const newsletterId = fixtureManager.get('newsletters', 0).id;
const member = await createMember({
email: 'unsubscribe-member-test@example.com',
newsletters: [
{id: newsletterId}
]
});
const memberUUID = member.get('uuid');
// Can fetch newsletter subscriptions
let getRes = await request
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 1);
await request.post(`/unsubscribe/?uuid=${memberUUID}`)
.expect(201);
getRes = await request
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
.expect(200);
getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 0, 'Sending a POST request to the unsubscribe endpoint should unsubscribe the member');
});
it('should only do a partial unsubscribe on POST', async function () {
const newsletterId = fixtureManager.get('newsletters', 0).id;
const newsletter2Id = fixtureManager.get('newsletters', 1).id;
const newsletter2Uuid = fixtureManager.get('newsletters', 1).uuid;
const member = await createMember({
email: 'unsubscribe-member-test-2@example.com',
newsletters: [
{id: newsletterId},
{id: newsletter2Id}
]
});
const memberUUID = member.get('uuid');
// Can fetch newsletter subscriptions
let getRes = await request
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 2);
await request.post(`/unsubscribe/?uuid=${memberUUID}&newsletter=${newsletter2Uuid}`)
.expect(201);
getRes = await request
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
.expect(200);
getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 1, 'Sending a POST request to the unsubscribe endpoint should unsubscribe the member from that specific newsletter');
});
it('should unsubscribe from comment notifications on POST', async function () {
const newsletterId = fixtureManager.get('newsletters', 0).id;
const member = await createMember({
email: 'unsubscribe-member-test-3@example.com',
newsletters: [
{id: newsletterId}
],
enable_comment_notifications: true
});
const memberUUID = member.get('uuid');
// Can fetch newsletter subscriptions
let getRes = await request
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 1);
await request.post(`/unsubscribe/?uuid=${memberUUID}&comments=1`)
.expect(201);
const updatedMember = await members.api.members.get({id: member.id}, {withRelated: ['newsletters']});
assert.equal(updatedMember.get('enable_comment_notifications'), false);
assert.equal(updatedMember.related('newsletters').models.length, 1);
});
it('unsubscribe post works with x-www-form-urlencoded', async function () {
const newsletterId = fixtureManager.get('newsletters', 0).id;
const member = await createMember({
email: 'unsubscribe-member-test-4@example.com',
newsletters: [
{id: newsletterId}
]
});
const memberUUID = member.get('uuid');
// Can fetch newsletter subscriptions
let getRes = await request
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 1);
await request.post(`/unsubscribe/?uuid=${memberUUID}`)
.type('form')
.send({'List-Unsubscribe': 'One-Click'})
.expect(201);
getRes = await request
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
.expect(200);
getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 0, 'Sending a POST request to the unsubscribe endpoint should unsubscribe the member');
});
it('unsubscribe post works with multipart/form-data', async function () {
const newsletterId = fixtureManager.get('newsletters', 0).id;
const member = await createMember({
email: 'unsubscribe-member-test-5@example.com',
newsletters: [
{id: newsletterId}
]
});
const memberUUID = member.get('uuid');
// Can fetch newsletter subscriptions
let getRes = await request
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 1);
await request.post(`/unsubscribe/?uuid=${memberUUID}`)
.field('List-Unsubscribe', 'One-Click')
.expect(201);
getRes = await request
.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
.expect(200);
getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 0, 'Sending a POST request to the unsubscribe endpoint should unsubscribe the member');
});
});
describe('Content gating', function () {

View File

@ -449,29 +449,6 @@ 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
*
@ -650,7 +627,8 @@ class EmailRenderer {
{
id: 'list_unsubscribe',
getValue: (member) => {
return '<' + this.createPostUnsubscribeUrl(member.uuid, {newsletterUuid}) + '>, <mailto:unsubscribe-' + member.uuid + '@ghost.org>';
// Same URL
return this.createUnsubscribeUrl(member.uuid, {newsletterUuid});
},
required: true // Used in email headers
}

View File

@ -156,7 +156,7 @@ describe('Email renderer', function () {
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], `<`);
assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=myuuid&newsletter=newsletteruuid`);
});
it('returns correct name', function () {

View File

@ -73,7 +73,7 @@ module.exports = class MailgunClient {
// 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'] = '<%unsubscribe_email%>, <%recipient.list_unsubscribe%>';
messageData['h:List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}