Skip to content

Commit 495ac27

Browse files
committed
Fix broadcast suppression wrongly blocking transactional emails
`check_email_suppressions` and `retry!` queried all active suppressions regardless of `stream_id`, so a newsletter unsubscribe (`broadcast` `ManualSuppression`) would prevent invoices and other transactional emails from being delivered. Now only `outbound` suppressions are checked for non-newsletter emails, matching the existing `HasEmails#active_emails` behavior. 46 transactional emails wrongly suppressed across 12 tenants.
1 parent 54f6d98 commit 495ac27

5 files changed

Lines changed: 118 additions & 10 deletions

File tree

app/models/mail_delivery/email.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ def suppress_invalid_address!
9797
end
9898

9999
def check_email_suppressions
100-
suppressions = EmailSuppression.active.where(email: email).select(:id, :reason)
100+
scope = EmailSuppression.active.where(email: email)
101+
scope = scope.outbound unless mail_delivery.newsletter?
102+
suppressions = scope.select(:id, :reason)
101103

102104
self.email_suppression_ids = suppressions.map(&:id)
103105
self.email_suppression_reasons = suppressions.map(&:reason).uniq

app/models/mail_delivery/email/retriable.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ module MailDelivery::Email::Retriable
1515
def retry!
1616
invalid_transition(:retry) unless suppressed?
1717

18-
fresh_suppressions = EmailSuppression.active.where(email: email).select(:id, :reason)
18+
scope = EmailSuppression.active.where(email: email)
19+
scope = scope.outbound unless mail_delivery.newsletter?
20+
fresh_suppressions = scope.select(:id, :reason)
1921

2022
if fresh_suppressions.any?
2123
update!(

test/mailers/concerns/templatable_test.rb

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -245,26 +245,26 @@ class TemplatableTest < ActionMailer::TestCase
245245
assert delivery.emails.first.processing?
246246
end
247247

248-
test "broadcast-suppressed email is marked as suppressed via process!" do
248+
test "broadcast-suppressed email is NOT blocked for transactional email" do
249249
template = mail_templates(:member_validated)
250250
template.update!(active: true)
251251
member = members(:john)
252252
member.update!(emails: "john@doe.com, extra@doe.com")
253-
suppression = suppress_email("extra@doe.com", stream_id: "broadcast")
253+
suppress_email("extra@doe.com", stream_id: "broadcast")
254254

255255
perform_enqueued_jobs do
256256
MailTemplate.deliver(:member_validated, member: member)
257257
end
258258

259-
# broadcast suppressions are NOT filtered by active_emails (only outbound are),
260-
# so both emails are in one delivery; the broadcast-suppressed one is marked suppressed
259+
# broadcast suppressions should not block transactional (non-newsletter) emails;
260+
# both emails are created and both are deliverable
261261
delivery = MailDelivery.where(member: member, mailable_type: "Member", action: "validated").last
262262
assert_equal 2, delivery.emails.count
263263

264-
suppressed_email = delivery.emails.find_by(email: "extra@doe.com")
265-
assert suppressed_email.suppressed?
266-
assert_equal [ suppression.id ], suppressed_email.email_suppression_ids
267-
assert_equal %w[HardBounce], suppressed_email.email_suppression_reasons
264+
extra_email = delivery.emails.find_by(email: "extra@doe.com")
265+
assert extra_email.processing?
266+
assert extra_email.deliverable?
267+
assert_empty extra_email.email_suppression_ids
268268

269269
clean_email = delivery.emails.find_by(email: "john@doe.com")
270270
assert clean_email.processing?

test/models/mail_delivery/email/retriable_test.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,37 @@ class MailDelivery::Email::RetriableTest < ActiveSupport::TestCase
5656
assert_equal %w[ManualSuppression], email.email_suppression_reasons
5757
end
5858

59+
test "retry! succeeds for transactional email with only broadcast suppression" do
60+
member = members(:john)
61+
suppress_email("john@doe.com", stream_id: "broadcast", reason: "ManualSuppression", origin: "Customer")
62+
63+
# Force-create a suppressed transactional email (simulating the old buggy behavior)
64+
delivery = MailDelivery.deliver!(
65+
member: member, mailable: invoices(:other_closed), action: "created",
66+
recipients: [ "john@doe.com" ])
67+
68+
email = delivery.emails.first
69+
suppression = EmailSuppression.active.broadcast.find_by(email: "john@doe.com")
70+
email.update_columns(
71+
state: "suppressed",
72+
email_suppression_ids: [ suppression.id ],
73+
email_suppression_reasons: [ "ManualSuppression" ])
74+
delivery.recompute_state!
75+
76+
assert email.reload.suppressed?
77+
78+
# retry! should now ignore the broadcast suppression for transactional emails
79+
assert_enqueued_with(job: MailDelivery::ProcessJob) do
80+
assert email.retry!
81+
end
82+
83+
email.reload
84+
assert email.processing?
85+
assert_empty email.email_suppression_ids
86+
assert_empty email.email_suppression_reasons
87+
assert_equal "processing", email.mail_delivery.reload.state
88+
end
89+
5990
test "retry! raises for non-suppressed email" do
6091
member = members(:john)
6192
delivery = MailDelivery.deliver!(

test/models/mail_delivery/email_test.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,79 @@ class MailDelivery::EmailTest < ActiveSupport::TestCase
248248
assert_not MailDelivery::Email.exists?(email.id)
249249
end
250250

251+
test "broadcast suppression does NOT block transactional email (invoice)" do
252+
member = members(:john)
253+
suppress_email("john@doe.com", stream_id: "broadcast", reason: "ManualSuppression", origin: "Customer")
254+
255+
delivery = MailDelivery.deliver!(
256+
member: member, mailable: invoices(:other_closed), action: "created")
257+
258+
email = delivery.emails.find_by(email: "john@doe.com")
259+
assert email.present?, "Email record should have been created"
260+
assert email.deliverable?, "Broadcast suppression should not block transactional email"
261+
assert_empty email.email_suppression_ids
262+
end
263+
264+
test "broadcast suppression DOES block newsletter email" do
265+
member = members(:john)
266+
suppress_email("john@doe.com", stream_id: "broadcast", reason: "ManualSuppression", origin: "Customer")
267+
268+
delivery = MailDelivery.deliver!(
269+
member: member, mailable: newsletters(:simple), action: "newsletter")
270+
271+
email = delivery.emails.find_by(email: "john@doe.com")
272+
assert email.present?, "Email record should have been created"
273+
assert_not email.deliverable?, "Broadcast suppression should block newsletter"
274+
assert_not_empty email.email_suppression_ids
275+
end
276+
277+
test "outbound suppression blocks transactional email" do
278+
member = members(:john)
279+
member.update!(emails: "john@doe.com, jojo@old.com")
280+
suppress_email("jojo@old.com", stream_id: "outbound")
281+
282+
delivery = MailDelivery.deliver!(
283+
member: member, mailable: invoices(:other_closed), action: "created")
284+
285+
# jojo@old.com should not even be a recipient (filtered by active_emails)
286+
assert_nil delivery.emails.find_by(email: "jojo@old.com")
287+
# john@doe.com should be deliverable
288+
email = delivery.emails.find_by(email: "john@doe.com")
289+
assert email.present?
290+
assert email.deliverable?
291+
end
292+
293+
test "outbound suppression DOES block transactional email deliverable?" do
294+
member = members(:john)
295+
suppress_email("john@doe.com", stream_id: "outbound")
296+
297+
delivery = MailDelivery.deliver!(
298+
member: member, mailable: invoices(:other_closed), action: "created",
299+
recipients: [ "john@doe.com" ])
300+
301+
email = delivery.emails.find_by(email: "john@doe.com")
302+
assert email.present?
303+
assert_not email.deliverable?, "Outbound suppression should block transactional email"
304+
assert_not_empty email.email_suppression_ids
305+
end
306+
307+
test "both broadcast and outbound suppressions block newsletter email" do
308+
member = members(:john)
309+
member.update!(emails: "john@doe.com, jojo@old.com")
310+
suppress_email("john@doe.com", stream_id: "broadcast", reason: "ManualSuppression", origin: "Customer")
311+
suppress_email("jojo@old.com", stream_id: "outbound")
312+
313+
delivery = MailDelivery.deliver!(
314+
member: member, mailable: newsletters(:simple), action: "newsletter",
315+
recipients: [ "john@doe.com", "jojo@old.com" ])
316+
317+
email_broadcast = delivery.emails.find_by(email: "john@doe.com")
318+
assert_not email_broadcast.deliverable?, "Broadcast suppression should block newsletter"
319+
320+
email_outbound = delivery.emails.find_by(email: "jojo@old.com")
321+
assert_not email_outbound.deliverable?, "Outbound suppression should block newsletter"
322+
end
323+
251324
test "process! handles InvalidEmailRequestError by creating InvalidAddress suppression" do
252325
member = members(:john)
253326
delivery = MailDelivery.deliver!(

0 commit comments

Comments
 (0)