Added List-Unsubscribe https endpoint (#18758)
refs TryGhost/Product#4052
This commit is contained in:
parent
14104f8f74
commit
6cc19e1851
@ -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) {
|
||||
|
@ -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 () {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 () {
|
||||
|
@ -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';
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user