Skip to content

Commit e9a81dd

Browse files
committed
Process monthly membership reminder bounces against member lists.
Site list bounces were previously forwarded to admins without scoring. Register reminder bounces on each subscribed list that sends reminders, while still forwarding owner moderation notices unchanged.
1 parent 757b76a commit e9a81dd

4 files changed

Lines changed: 226 additions & 19 deletions

File tree

Mailman/Defaults.py.in

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -836,12 +836,16 @@ VERP_PROBES = No
836836

837837
# A perfect opportunity for doing VERP is the password reminders, which are
838838
# already addressed individually to each recipient. Set this to Yes to enable
839-
# VERPs on all password reminders. However, because password reminders are
840-
# sent from the site list and site list bounces aren't processed but are just
841-
# forwarded to the site list admins, this isn't too useful. See comments at
842-
# lines 70-84 of Mailman/Queue/BounceRunner.py for why we don't process them.
839+
# VERPs on all password reminders. This is useful when
840+
# BOUNCE_PROCESS_REMINDER_BOUNCES is enabled.
843841
VERP_PASSWORD_REMINDERS = No
844842

843+
# When a monthly membership/password reminder bounces, register the bounce
844+
# against every list the member is subscribed to that sends reminders and has
845+
# bounce processing enabled. Other site list bounces (e.g. owner moderation
846+
# notices) are still forwarded to the site list administrators.
847+
BOUNCE_PROCESS_REMINDER_BOUNCES = Yes
848+
845849
# Another good opportunity is when regular delivery is personalized. Here
846850
# again, we're already incurring the performance hit for addressing each
847851
# individual recipient. Set this to Yes to enable VERPs on all personalized

Mailman/Queue/BounceRunner.py

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@
2323
import time
2424
import pickle
2525

26+
from email.iterators import typed_subpart_iterator
2627
from email.mime.text import MIMEText
2728
from email.mime.message import MIMEMessage
2829
from email.utils import parseaddr
2930

3031
from Mailman import mm_cfg
3132
from Mailman import Utils
33+
from Mailman.MailList import MailList
3234
from Mailman import LockFile
3335
from Mailman.Errors import NotAMemberError
3436
from Mailman.Message import UserNotification
@@ -74,8 +76,9 @@ def __init__(self):
7476
# site list's bounce address. The bounce runner would then dutifully
7577
# register a bounce for all 4 lists that aperson@example.com was a
7678
# member of, and eventually that person would get disabled on all
77-
# their lists. So now we ignore site list bounces. Ce La Vie for
78-
# password reminder bounces.
79+
# their lists. So now we ignore most site list bounces. Membership
80+
# reminder bounces can optionally be processed; see
81+
# BOUNCE_PROCESS_REMINDER_BOUNCES.
7982
self._bounce_events_file = os.path.join(
8083
mm_cfg.DATA_DIR, 'bounce-events-%05d.pck' % os.getpid())
8184
self._bounce_events_fp = None
@@ -97,6 +100,22 @@ def _queue_bounces(self, listname, addrs, msg):
97100
os.fsync(self._bounce_events_fp.fileno())
98101
self._bouncecnt += len(addrs)
99102

103+
def _queue_reminder_bounces(self, addr, msg):
104+
"""Queue a reminder bounce for each subscribed list that sends reminders."""
105+
for listname in Utils.list_names():
106+
if listname.lower() == mm_cfg.MAILMAN_SITE_LIST.lower():
107+
continue
108+
mlist = MailList(listname, lock=0)
109+
if not mlist.bounce_processing:
110+
continue
111+
if not mlist.send_reminders:
112+
continue
113+
if not mlist.isMember(addr):
114+
continue
115+
if mlist.getMemberOption(addr, mm_cfg.SuppressPasswordReminder):
116+
continue
117+
self._queue_bounces(listname, [addr], msg)
118+
100119
def _register_bounces(self):
101120
syslog('bounce', '%s processing %s queued bounces',
102121
self, self._bouncecnt)
@@ -206,18 +225,24 @@ def _dispose(self, mlist, msg, msgdata):
206225
# message sent directly to the -bounces address. We have to do these
207226
# cases separately, because sending to site-owner will reset the
208227
# envelope sender.
228+
is_site_list = (mlist.internal_name().lower() ==
229+
mm_cfg.MAILMAN_SITE_LIST.lower())
230+
process_reminder_bounce = False
209231
# Is this a site list bounce?
210-
if (mlist.internal_name().lower() ==
211-
mm_cfg.MAILMAN_SITE_LIST.lower()):
212-
# Send it on to the site owners, but craft the envelope sender to
213-
# be the -loop detection address, so if /they/ bounce, we won't
214-
# get stuck in a bounce loop.
215-
outq.enqueue(msg, msgdata,
216-
recips=mlist.owner,
217-
envsender=Utils.get_site_email(extra='loop'),
218-
nodecorate=1,
219-
)
220-
return
232+
if is_site_list:
233+
if (mm_cfg.BOUNCE_PROCESS_REMINDER_BOUNCES and
234+
is_membership_reminder_bounce(msg)):
235+
process_reminder_bounce = True
236+
else:
237+
# Send it on to the site owners, but craft the envelope sender
238+
# to be the -loop detection address, so if /they/ bounce, we
239+
# won't get stuck in a bounce loop.
240+
outq.enqueue(msg, msgdata,
241+
recips=mlist.owner,
242+
envsender=Utils.get_site_email(extra='loop'),
243+
nodecorate=1,
244+
)
245+
return
221246
# Is this a possible looping message sent directly to a list-bounces
222247
# address other than the site list?
223248
# Check From: because unix_from might be VERP'd.
@@ -233,7 +258,7 @@ def _dispose(self, mlist, msg, msgdata):
233258
)
234259
return
235260
# List isn't doing bounce processing?
236-
if not mlist.bounce_processing:
261+
if not process_reminder_bounce and not mlist.bounce_processing:
237262
return
238263
# Try VERP detection first, since it's quick and easy
239264
addrs = verp_bounce(mlist, msg)
@@ -268,7 +293,11 @@ def _dispose(self, mlist, msg, msgdata):
268293
# can let None's sneak through. In any event, this will kill them.
269294
# addrs = filter(None, addrs)
270295
# MAS above filter moved up so we don't try to queue an empty list.
271-
self._queue_bounces(mlist.internal_name(), addrs, msg)
296+
if process_reminder_bounce:
297+
for addr in addrs:
298+
self._queue_reminder_bounces(addr, msg)
299+
else:
300+
self._queue_bounces(mlist.internal_name(), addrs, msg)
272301

273302
_doperiodic = BounceMixin._doperiodic
274303

@@ -277,6 +306,40 @@ def _cleanup(self):
277306
Runner._cleanup(self)
278307

279308

309+
310+
def is_membership_reminder_bounce(msg):
311+
"""Return true if this bounce is for a monthly membership reminder."""
312+
for part in typed_subpart_iterator(msg, 'message', 'rfc822'):
313+
orig = part.get_payload(0)
314+
if orig is None:
315+
continue
316+
if orig.get('X-Mailman-Membership-Reminder', '').lower() == 'yes':
317+
return True
318+
if orig.get('x-list-administrivia', '').lower() != 'yes':
319+
continue
320+
sender = parseaddr(orig.get('from', ''))[1].lower()
321+
if not sender:
322+
continue
323+
# Owner notifications are sent from the site -bounces address.
324+
if _is_site_bounces_address(sender):
325+
continue
326+
subj = Utils.oneline(orig.get('subject', ''), 'us-ascii').lower()
327+
if 'reminder' in subj:
328+
return True
329+
return False
330+
331+
332+
333+
def _is_site_bounces_address(addr):
334+
addr = addr.lower()
335+
mailbox, domain = Utils.ParseEmail(addr)
336+
if mailbox == '%s-bounces' % mm_cfg.MAILMAN_SITE_LIST:
337+
return True
338+
if mailbox.startswith('%s-bounces+' % mm_cfg.MAILMAN_SITE_LIST):
339+
return True
340+
return False
341+
342+
280343

281344
def verp_bounce(mlist, msg):
282345
bmailbox, bdomain = Utils.ParseEmail(mlist.GetBouncesEmail())

cron/mailpasswds

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ def main():
225225
finally:
226226
i18n.set_translation(otrans)
227227
msg['X-No-Archive'] = 'yes'
228+
msg['X-Mailman-Membership-Reminder'] = 'yes'
228229
del msg['auto-submitted']
229230
msg['Auto-Submitted'] = 'auto-generated'
230231
# We want to make this look like it's coming from the siteowner's

tests/test_bounce_reminders.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Copyright (C) 2026 by the Free Software Foundation, Inc.
2+
#
3+
# This program is free software; you can redistribute it and/or
4+
# modify it under the terms of the GNU General Public License
5+
# as published by the Free Software Foundation; either version 2
6+
# of the License, or (at your option) any later version.
7+
8+
"""Test membership reminder bounce handling."""
9+
10+
import os
11+
import pickle
12+
import unittest
13+
import email
14+
15+
_TESTDIR = os.path.dirname(os.path.abspath(__file__))
16+
try:
17+
from Mailman import __init__
18+
except ImportError:
19+
import paths
20+
21+
from Mailman import Utils
22+
from Mailman.Queue.BounceRunner import is_membership_reminder_bounce
23+
24+
25+
SAMPLE = os.path.join(
26+
_TESTDIR, 'bounces', 'samples_from_messages',
27+
'sample_01_undelivered_mail_returned_to_sender.eml')
28+
29+
30+
31+
class ReminderBounceTest(unittest.TestCase):
32+
def test_membership_reminder_sample(self):
33+
if not os.path.exists(SAMPLE):
34+
self.skipTest('sample bounce file not available')
35+
with open(SAMPLE, 'rb') as fp:
36+
msg = email.message_from_binary_file(fp)
37+
self.assertTrue(is_membership_reminder_bounce(msg))
38+
39+
def test_owner_notification_is_not_reminder(self):
40+
msg = email.message_from_string("""\
41+
Content-Type: multipart/report; boundary="BOUND"
42+
43+
--BOUND
44+
Content-Type: text/plain
45+
46+
A moderation notice bounced.
47+
48+
--BOUND
49+
Content-Type: message/rfc822
50+
51+
From: mailman-bounces@dom.ain
52+
To: mylist-owner@dom.ain
53+
Subject: Your message awaits moderator approval
54+
X-List-Administrivia: yes
55+
56+
The held message.
57+
58+
--BOUND--
59+
""")
60+
self.assertFalse(is_membership_reminder_bounce(msg))
61+
62+
def test_explicit_header(self):
63+
msg = email.message_from_string("""\
64+
Content-Type: multipart/report; boundary="BOUND"
65+
66+
--BOUND
67+
Content-Type: message/rfc822
68+
69+
From: mailman-owner@dom.ain
70+
To: user@example.com
71+
Subject: anything
72+
X-Mailman-Membership-Reminder: yes
73+
74+
Reminder body.
75+
76+
--BOUND--
77+
""")
78+
self.assertTrue(is_membership_reminder_bounce(msg))
79+
80+
81+
82+
class ReminderBounceQueueTest(unittest.TestCase):
83+
def test_queue_reminder_bounces(self):
84+
from Mailman.Queue.BounceRunner import BounceMixin
85+
import Mailman.Queue.BounceRunner as br_mod
86+
from Mailman import mm_cfg
87+
88+
class FakeList(object):
89+
bounce_processing = 1
90+
send_reminders = 1
91+
92+
def isMember(self, addr):
93+
return addr == 'user@example.com'
94+
95+
def getMemberOption(self, addr, option):
96+
return 0
97+
98+
class TestMixin(BounceMixin):
99+
pass
100+
101+
runner = TestMixin()
102+
msg = email.message_from_string('Subject: bounce\n\n')
103+
old_list_names = Utils.list_names
104+
old_mail_list = br_mod.MailList
105+
try:
106+
Utils.list_names = lambda: [mm_cfg.MAILMAN_SITE_LIST, 'mylist']
107+
br_mod.MailList = lambda name, lock=0: FakeList()
108+
runner._queue_reminder_bounces('user@example.com', msg)
109+
finally:
110+
Utils.list_names = old_list_names
111+
br_mod.MailList = old_mail_list
112+
113+
runner._bounce_events_fp.seek(0)
114+
events = []
115+
while True:
116+
try:
117+
events.append(pickle.load(runner._bounce_events_fp))
118+
except EOFError:
119+
break
120+
self.assertEqual(len(events), 1)
121+
self.assertEqual(events[0][0], 'mylist')
122+
self.assertEqual(events[0][1], 'user@example.com')
123+
runner._bounce_events_fp.close()
124+
os.unlink(runner._bounce_events_file)
125+
runner._bounce_events_fp = None
126+
runner._bouncecnt = 0
127+
128+
129+
130+
def suite():
131+
suite = unittest.TestSuite()
132+
suite.addTest(unittest.makeSuite(ReminderBounceTest))
133+
suite.addTest(unittest.makeSuite(ReminderBounceQueueTest))
134+
return suite
135+
136+
137+
138+
if __name__ == '__main__':
139+
unittest.main(defaultTest='suite')

0 commit comments

Comments
 (0)