0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-25 02:31:59 -05:00

Removed email from Mailgun's suppression list on unsubscribe (#18922)

closes https://github.com/TryGhost/Product/issues/4075

- when a member clicks on "Unsubscribe from that list" from Apple Mail,
the member's email is put into Mailgun's Unsubscribe suppression list.
Ghost listens for "Unsubscribe" events from Mailgun, and unsubscribes
the member from all the newsletters
- now, the member is only unsubscribed from the newsletter they
unsubscribe to (not all of them)
- now, the email is also deleted from Mailgun's suppression list, so
that it doesn't affect any other membership
This commit is contained in:
Sag 2023-11-13 16:56:37 -03:00 committed by GitHub
parent 3513ef3032
commit 6db7cc8156
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 115 additions and 17 deletions

View file

@ -9,7 +9,7 @@ class EmailAnalyticsServiceWrapper {
const {EmailAnalyticsService} = require('@tryghost/email-analytics-service');
const {EmailEventStorage, EmailEventProcessor} = require('@tryghost/email-service');
const MailgunProvider = require('@tryghost/email-analytics-provider-mailgun');
const {EmailRecipientFailure, EmailSpamComplaintEvent} = require('../../models');
const {EmailRecipientFailure, EmailSpamComplaintEvent, Email} = require('../../models');
const StartEmailAnalyticsJobEvent = require('./events/StartEmailAnalyticsJobEvent');
const domainEvents = require('@tryghost/domain-events');
@ -19,14 +19,17 @@ class EmailAnalyticsServiceWrapper {
const queries = require('./lib/queries');
const membersService = require('../members');
const membersRepository = membersService.api.members;
const emailSuppressionList = require('../email-suppression-list');
this.eventStorage = new EmailEventStorage({
db,
membersRepository,
models: {
Email,
EmailRecipientFailure,
EmailSpamComplaintEvent
}
},
emailSuppressionList
});
// Since this is running in a worker thread, we cant dispatch directly

View file

@ -47,6 +47,15 @@ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
return true;
}
async removeUnsubscribe(email) {
try {
await this.apiClient.removeUnsubscribe(email);
} catch (err) {
logging.error(err);
return false;
}
}
async getSuppressionData(email) {
try {
const model = await this.Suppression.findOne({

View file

@ -1028,31 +1028,41 @@ describe('EmailEventStorage', function () {
});
it('Can handle unsubscribe events', async function () {
const newsletterToRemove = fixtureManager.get('newsletters', 0).id;
const newsletterToKeep = fixtureManager.get('newsletters', 1).id;
const email = fixtureManager.get('emails', 0);
await models.Email.edit({newsletter_id: newsletterToRemove}, {id: email.id});
const emailBatch = fixtureManager.get('email_batches', 0);
const emailId = emailBatch.email_id;
assert(emailBatch.email_id === email.id);
const emailRecipient = fixtureManager.get('email_recipients', 0);
assert(emailRecipient.batch_id === emailBatch.id);
const memberId = emailRecipient.member_id;
const providerId = emailBatch.provider_id;
const timestamp = new Date(2000, 0, 1);
// Reset
// Initialise member with 2 newsletters
await membersService.api.members.update({newsletters: [
{
id: fixtureManager.get('newsletters', 0).id
id: newsletterToRemove
},
{
id: newsletterToKeep
}
]}, {id: memberId});
// Check not unsubscribed
// Check that the member is subscribed to 2 newsletters
const memberInitial = await membersService.api.members.get({id: memberId}, {withRelated: ['newsletters']});
assert.notEqual(memberInitial.related('newsletters').length, 0, 'This test requires a member that is subscribed to at least one newsletter');
assert.equal(memberInitial.related('newsletters').length, 2, 'This test requires a member that is subscribed to at least one newsletter');
events = [{
event: 'unsubscribed',
recipient: emailRecipient.member_email,
'user-variables': {
'email-id': emailId
'email-id': email.id
},
message: {
headers: {
@ -1070,9 +1080,17 @@ describe('EmailEventStorage', function () {
// Since this is all event based we should wait for all dispatched events to be completed.
await DomainEvents.allSettled();
// Check if unsubscribed
// The member should be unsubscribed from the specific newsletter
const member = await membersService.api.members.get({id: memberId}, {withRelated: ['newsletters']});
assert.equal(member.related('newsletters').length, 0);
// The member is now subscribed to 1 newsletter
assert.equal(member.related('newsletters').length, 1);
// The member is now unsubscribed from newsletter 0
assert(!member.related('newsletters').models.some(newsletter => newsletter.id === newsletterToRemove));
// But the member is still subscribed to newsletter 1
assert(member.related('newsletters').models.some(newsletter => newsletter.id === newsletterToKeep));
});
it('Can handle unknown events', async function () {

View file

@ -736,7 +736,8 @@ DataGenerator.Content = {
html: '<p>Look! I\'m an email</p>',
plaintext: 'Waba-daba-dab-da',
track_opens: false,
submitted_at: moment().toDate()
submitted_at: moment().toDate(),
newsletter_id: null // newsletter[0] relation added later
},
{
id: ObjectId().toHexString(),
@ -748,7 +749,8 @@ DataGenerator.Content = {
html: '<p>What\'s that? Another email!</p>',
plaintext: 'yes this is an email',
track_opens: false,
submitted_at: moment().toDate()
submitted_at: moment().toDate(),
newsletter_id: null // newsletter[1] relation added later
}
],

View file

@ -5,11 +5,13 @@ class EmailEventStorage {
#db;
#membersRepository;
#models;
#emailSuppressionList;
constructor({db, models, membersRepository}) {
constructor({db, models, membersRepository, emailSuppressionList}) {
this.#db = db;
this.#models = models;
this.#membersRepository = membersRepository;
this.#emailSuppressionList = emailSuppressionList;
}
async handleDelivered(event) {
@ -111,7 +113,16 @@ class EmailEventStorage {
}
async handleUnsubscribed(event) {
return this.unsubscribeFromNewsletters(event);
try {
// Unsubscribe member from the specific newsletter
const newsletters = await this.findNewslettersToKeep(event);
await this.#membersRepository.update({newsletters}, {id: event.memberId});
// Remove member from Mailgun's suppression list
await this.#emailSuppressionList.removeUnsubscribe(event.email);
} catch (err) {
logging.error(err);
}
}
async handleComplained(event) {
@ -128,11 +139,22 @@ class EmailEventStorage {
}
}
async unsubscribeFromNewsletters(event) {
async findNewslettersToKeep(event) {
try {
await this.#membersRepository.update({newsletters: []}, {id: event.memberId});
const member = await this.#membersRepository.get({email: event.email}, {
withRelated: ['newsletters']
});
const existingNewsletters = member.related('newsletters');
const email = await this.#models.Email.findOne({id: event.emailId});
const newsletterToRemove = email.get('newsletter_id');
return existingNewsletters.models.filter(newsletter => newsletter.id !== newsletterToRemove).map((n) => {
return {id: n.id};
});
} catch (err) {
logging.error(err);
return [];
}
}
}

View file

@ -456,14 +456,20 @@ describe('Email Event Storage', function () {
const update = sinon.stub().resolves();
const emailSuppressionList = {
removeUnsubscribe: sinon.stub().resolves()
};
const eventHandler = new EmailEventStorage({
membersRepository: {
update
}
},
emailSuppressionList
});
await eventHandler.handleUnsubscribed(event);
assert(update.calledOnce);
assert(update.firstCall.args[0].newsletters.length === 0);
assert(emailSuppressionList.removeUnsubscribe.calledOnce);
});
it('Handles unsubscribe with a non-existent member', async function () {
@ -487,6 +493,44 @@ describe('Email Event Storage', function () {
assert(update.firstCall.args[0].newsletters.length === 0);
});
it('Finds newsletters to keep during an unsubscribe', async function () {
const event = EmailUnsubscribedEvent.create({
email: 'example@example.com',
memberId: '123',
emailId: '456',
timestamp: new Date(0)
});
const Email = {
findOne: sinon.stub().resolves({
get: sinon.stub().returns('newsletter_1')
})
};
const membersRepository = {
get: sinon.stub().resolves({
related: sinon.stub().returns({
models: [
{id: 'newsletter_1'},
{id: 'newsletter_2'}
]
})
})
};
const eventHandler = new EmailEventStorage({
membersRepository,
models: {
Email
}
});
const result = await eventHandler.findNewslettersToKeep(event);
assert(result.length === 1);
assert(result[0].id === 'newsletter_2');
});
it('Handles complaints', async function () {
const event = SpamComplaintEvent.create({
email: 'example@example.com',