Skip to content

Commit abfbb2e

Browse files
committed
🐛 Fixed members inheriting stale suppression state on resignup
ref ONC-1640 When a member whose email was previously suppressed was deleted and a new member signed up with the same address, the new member was created with email_disabled=false despite the suppression record still existing. The send filter then included them in every newsletter, Mailgun rejected each send with a 607, and the handler silently swallowed the ER_DUP_ENTRY from trying to re-insert the existing suppression record — so email_disabled was never corrected. - member-bread-service.add() now checks the suppression list and sets email_disabled, mirroring edit() - mailgun-email-suppression-list handleEvent now dispatches EmailSuppressedEvent even on ER_DUP_ENTRY, self-healing drifted members on their next bounce/complaint
1 parent 8ea5257 commit abfbb2e

4 files changed

Lines changed: 167 additions & 15 deletions

File tree

ghost/core/core/server/services/email-suppression-list/mailgun-email-suppression-list.js

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -117,31 +117,35 @@ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
117117
async init() {
118118
this.Suppression = models.Suppression;
119119
const handleEvent = reason => async (event) => {
120-
try {
121-
if (reason === 'bounce') {
122-
if (!Number.isInteger(event.error?.code)) {
123-
return;
124-
}
125-
if (event.error.code !== 607 && event.error.code !== 605) {
126-
return;
127-
}
120+
if (reason === 'bounce') {
121+
if (!Number.isInteger(event.error?.code)) {
122+
return;
123+
}
124+
if (event.error.code !== 607 && event.error.code !== 605) {
125+
return;
128126
}
127+
}
128+
try {
129129
await this.Suppression.add({
130130
email: event.email,
131131
email_id: event.emailId,
132132
reason: reason,
133133
created_at: event.timestamp
134134
});
135-
DomainEvents.dispatch(EmailSuppressedEvent.create({
136-
emailAddress: event.email,
137-
emailId: event.emailId,
138-
reason: reason
139-
}, event.timestamp));
140135
} catch (err) {
141136
if (err.code !== 'ER_DUP_ENTRY') {
142137
logging.error(err);
138+
return;
143139
}
140+
// Suppression already exists — still dispatch so any drifted
141+
// member state (e.g. email_disabled=false) gets corrected.
142+
logging.info(`Re-dispatching EmailSuppressedEvent for existing suppression (${reason}): ${event.email}`);
144143
}
144+
DomainEvents.dispatch(EmailSuppressedEvent.create({
145+
emailAddress: event.email,
146+
emailId: event.emailId,
147+
reason: reason
148+
}, event.timestamp));
145149
};
146150
DomainEvents.subscribe(EmailBouncedEvent, handleEvent('bounce'));
147151
DomainEvents.subscribe(SpamComplaintEvent, handleEvent('spam'));

ghost/core/core/server/services/members/members-api/services/member-bread-service.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,11 @@ module.exports = class MemberBREADService {
348348
let model;
349349

350350
try {
351+
if (data.email) {
352+
const isSuppressed = (await this.emailSuppressionList.getSuppressionData(data.email))?.suppressed;
353+
data.email_disabled = !!isSuppressed;
354+
}
355+
351356
const attribution = await this.memberAttributionService.getAttributionFromContext(options?.context);
352357
if (attribution) {
353358
data.attribution = attribution;

ghost/core/test/integration/services/mailgun-email-suppression-list.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const DomainEvents = require('@tryghost/domain-events');
55

66
const MailgunClient = require('../../../core/server/services/lib/mailgun-client');
77
const emailAnalytics = require('../../../core/server/services/email-analytics');
8+
const models = require('../../../core/server/models');
89

910
describe('MailgunEmailSuppressionList', function () {
1011
let agent;
@@ -171,6 +172,46 @@ describe('MailgunEmailSuppressionList', function () {
171172
assert.equal(memberAfter.email_suppression.suppressed, true, 'The member should have a suppressed email');
172173
assert.equal(memberAfter.email_suppression.info.reason, 'spam');
173174
});
175+
176+
it('Sets email_disabled on the member when a suppression record already exists (ONC-1640)', async function () {
177+
// Regression test: if a Suppression record already exists for the address
178+
// (e.g. from a deleted previous member), a bounce event for a new member
179+
// with the same address used to silently ER_DUP_ENTRY and never dispatch
180+
// EmailSuppressedEvent, leaving the member with email_disabled=false forever.
181+
const emailBatch = fixtureManager.get('email_batches', 0);
182+
const emailRecipient = fixtureManager.get('email_recipients', 5);
183+
assert(emailRecipient.batch_id === emailBatch.id);
184+
const memberId = emailRecipient.member_id;
185+
const providerId = emailBatch.provider_id;
186+
const recipient = emailRecipient.member_email;
187+
const timestamp = new Date(2000, 0, 1);
188+
189+
await models.Suppression.add({
190+
email: recipient,
191+
email_id: emailBatch.email_id,
192+
reason: 'bounce',
193+
created_at: new Date(1999, 0, 1)
194+
});
195+
196+
const memberBefore = await models.Member.findOne({id: memberId}, {require: true});
197+
assert.equal(memberBefore.get('email_disabled'), false, 'Test setup: the member should start with email_disabled=false');
198+
199+
events = [createPermanentFailedEvent({
200+
errorCode: 605,
201+
providerId,
202+
timestamp,
203+
recipient
204+
})];
205+
206+
await emailAnalytics.fetchLatestOpenedEvents();
207+
await DomainEvents.allSettled();
208+
209+
const memberAfter = await models.Member.findOne({id: memberId}, {require: true});
210+
assert.equal(memberAfter.get('email_disabled'), true, 'Member should be disabled even when the Suppression record already existed');
211+
212+
// Clean up so subsequent tests using this email aren't affected
213+
await models.Suppression.destroy({destroyBy: {email: recipient}});
214+
});
174215
});
175216

176217
function createPermanentFailedEvent({errorCode, providerId, timestamp, recipient}) {

ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe('MemberBreadService', function () {
4545

4646
const linkStripeCustomerStub = sinon.stub().resolves();
4747
const createStub = sinon.stub().resolves(mockMemberModel);
48+
const getSuppressionDataStub = sinon.stub().resolves({suppressed: false, info: null});
4849

4950
const memberRepository = {
5051
create: createStub,
@@ -60,7 +61,7 @@ describe('MemberBreadService', function () {
6061
labsService: {isSet: sinon.stub().returns(false)},
6162
newslettersService: {browse: sinon.stub().resolves([])},
6263
settingsCache: {get: sinon.stub()},
63-
emailSuppressionList: {getSuppressionData: sinon.stub().resolves({suppressed: false, info: null})},
64+
emailSuppressionList: {getSuppressionData: getSuppressionDataStub},
6465
settingsHelpers: {createUnsubscribeUrl: sinon.stub().returns('http://example.com/unsubscribe')}
6566
});
6667

@@ -72,7 +73,7 @@ describe('MemberBreadService', function () {
7273
status: 'free'
7374
});
7475

75-
return {service, memberRepository, linkStripeCustomerStub, createStub};
76+
return {service, memberRepository, linkStripeCustomerStub, createStub, getSuppressionDataStub};
7677
}
7778

7879
it('passes context to linkStripeCustomer when stripe_customer_id is provided', async function () {
@@ -149,6 +150,107 @@ describe('MemberBreadService', function () {
149150
assert.ok(linkStripeCustomerOptions.context, 'context should be passed to linkStripeCustomer');
150151
assert.equal(linkStripeCustomerOptions.context.import, true, 'context.import should be true');
151152
});
153+
154+
it('sets email_disabled to true when the email is on the suppression list', async function () {
155+
// Prevents ONC-1640: a previous member with this email bounced/complained,
156+
// was deleted, and the suppression record remains. A new signup with that
157+
// same address must inherit the disabled state instead of starting clean.
158+
const {service, createStub, getSuppressionDataStub} = createService();
159+
getSuppressionDataStub.resolves({suppressed: true, info: {reason: 'spam'}});
160+
161+
await service.add({
162+
email: 'suppressed@example.com',
163+
name: 'New Signup'
164+
}, {});
165+
166+
assert.equal(createStub.calledOnce, true);
167+
const createdData = createStub.firstCall.args[0];
168+
assert.equal(createdData.email_disabled, true);
169+
});
170+
171+
it('sets email_disabled to false when the email is not on the suppression list', async function () {
172+
const {service, createStub} = createService();
173+
174+
await service.add({
175+
email: 'clean@example.com',
176+
name: 'Clean Signup'
177+
}, {});
178+
179+
assert.equal(createStub.calledOnce, true);
180+
const createdData = createStub.firstCall.args[0];
181+
assert.equal(createdData.email_disabled, false);
182+
});
183+
});
184+
185+
describe('edit', function () {
186+
function createMockMemberModel() {
187+
return {
188+
id: 'member_123',
189+
get: sinon.stub().returns(false),
190+
related: sinon.stub().returns({find: () => null, toJSON: () => [], models: []}),
191+
toJSON: sinon.stub().returns({
192+
id: 'member_123',
193+
email: 'test@example.com'
194+
})
195+
};
196+
}
197+
198+
function createService() {
199+
const mockMemberModel = createMockMemberModel();
200+
const updateStub = sinon.stub().resolves(mockMemberModel);
201+
const getSuppressionDataStub = sinon.stub().resolves({suppressed: false, info: null});
202+
203+
const service = new MemberBreadService({
204+
memberRepository: {
205+
update: updateStub
206+
},
207+
stripeService: {configured: false},
208+
memberAttributionService: {getAttributionFromContext: sinon.stub().resolves(null)},
209+
emailService: {},
210+
labsService: {isSet: sinon.stub().returns(false)},
211+
newslettersService: {browse: sinon.stub().resolves([])},
212+
settingsCache: {get: sinon.stub()},
213+
emailSuppressionList: {getSuppressionData: getSuppressionDataStub},
214+
settingsHelpers: {createUnsubscribeUrl: sinon.stub().returns('http://example.com/unsubscribe')}
215+
});
216+
217+
sinon.stub(service, 'read').resolves({id: 'member_123'});
218+
219+
return {service, updateStub, getSuppressionDataStub};
220+
}
221+
222+
it('sets email_disabled to true when the new email is on the suppression list', async function () {
223+
const {service, updateStub, getSuppressionDataStub} = createService();
224+
getSuppressionDataStub.resolves({suppressed: true, info: {reason: 'spam'}});
225+
226+
await service.edit({
227+
email: 'suppressed@example.com'
228+
}, {id: 'member_123'});
229+
230+
const updatedData = updateStub.firstCall.args[0];
231+
assert.equal(updatedData.email_disabled, true);
232+
});
233+
234+
it('sets email_disabled to false when the new email is not on the suppression list', async function () {
235+
const {service, updateStub} = createService();
236+
237+
await service.edit({
238+
email: 'clean@example.com'
239+
}, {id: 'member_123'});
240+
241+
const updatedData = updateStub.firstCall.args[0];
242+
assert.equal(updatedData.email_disabled, false);
243+
});
244+
245+
it('does not check the suppression list when email is not being changed', async function () {
246+
const {service, getSuppressionDataStub} = createService();
247+
248+
await service.edit({
249+
name: 'New Name'
250+
}, {id: 'member_123'});
251+
252+
assert.equal(getSuppressionDataStub.called, false);
253+
});
152254
});
153255

154256
describe('read', function () {

0 commit comments

Comments
 (0)