Mangle differently
[mspang/vmailman.git] / Mailman / Bouncer.py
1 # Copyright (C) 1998-2005 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 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software
15 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
16 # USA.
17
18 """Handle delivery bounces."""
19
20 import sys
21 import time
22 from types import StringType
23
24 from email.MIMEText import MIMEText
25 from email.MIMEMessage import MIMEMessage
26
27 from Mailman import mm_cfg
28 from Mailman import Utils
29 from Mailman import Message
30 from Mailman import MemberAdaptor
31 from Mailman import Pending
32 from Mailman.Logging.Syslog import syslog
33 from Mailman import i18n
34
35 EMPTYSTRING = ''
36
37 # This constant is supposed to represent the day containing the first midnight
38 # after the epoch.  We'll add (0,)*6 to this tuple to get a value appropriate
39 # for time.mktime().
40 ZEROHOUR_PLUSONEDAY = time.localtime(mm_cfg.days(1))[:3]
41
42 def _(s): return s
43
44 REASONS = {MemberAdaptor.BYBOUNCE: _('due to excessive bounces'),
45            MemberAdaptor.BYUSER: _('by yourself'),
46            MemberAdaptor.BYADMIN: _('by the list administrator'),
47            MemberAdaptor.UNKNOWN: _('for unknown reasons'),
48            }
49
50 _ = i18n._
51
52
53 \f
54 class _BounceInfo:
55     def __init__(self, member, score, date, noticesleft):
56         self.member = member
57         self.cookie = None
58         self.reset(score, date, noticesleft)
59
60     def reset(self, score, date, noticesleft):
61         self.score = score
62         self.date = date
63         self.noticesleft = noticesleft
64         self.lastnotice = ZEROHOUR_PLUSONEDAY
65
66     def __repr__(self):
67         # For debugging
68         return """\
69 <bounce info for member %(member)s
70         current score: %(score)s
71         last bounce date: %(date)s
72         email notices left: %(noticesleft)s
73         last notice date: %(lastnotice)s
74         confirmation cookie: %(cookie)s
75         >""" % self.__dict__
76
77
78 \f
79 class Bouncer:
80     def InitVars(self):
81         # Configurable...
82         self.bounce_processing = mm_cfg.DEFAULT_BOUNCE_PROCESSING
83         self.bounce_score_threshold = mm_cfg.DEFAULT_BOUNCE_SCORE_THRESHOLD
84         self.bounce_info_stale_after = mm_cfg.DEFAULT_BOUNCE_INFO_STALE_AFTER
85         self.bounce_you_are_disabled_warnings = \
86             mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS
87         self.bounce_you_are_disabled_warnings_interval = \
88             mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS_INTERVAL
89         self.bounce_unrecognized_goes_to_list_owner = \
90             mm_cfg.DEFAULT_BOUNCE_UNRECOGNIZED_GOES_TO_LIST_OWNER
91         self.bounce_notify_owner_on_disable = \
92             mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_DISABLE
93         self.bounce_notify_owner_on_removal = \
94             mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL
95         # Not configurable...
96         #
97         # This holds legacy member related information.  It's keyed by the
98         # member address, and the value is an object containing the bounce
99         # score, the date of the last received bounce, and a count of the
100         # notifications left to send.
101         self.bounce_info = {}
102         # New style delivery status
103         self.delivery_status = {}
104
105     def registerBounce(self, member, msg, weight=1.0, day=None):
106         if not self.isMember(member):
107             return
108         info = self.getBounceInfo(member)
109         if day is None:
110             # Use today's date
111             day = time.localtime()[:3]
112         if not isinstance(info, _BounceInfo):
113             # This is the first bounce we've seen from this member
114             info = _BounceInfo(member, weight, day,
115                                self.bounce_you_are_disabled_warnings)
116             self.setBounceInfo(member, info)
117             syslog('bounce', '%s: %s bounce score: %s', self.internal_name(),
118                    member, info.score)
119             # Continue to the check phase below
120         elif self.getDeliveryStatus(member) <> MemberAdaptor.ENABLED:
121             # The user is already disabled, so we can just ignore subsequent
122             # bounces.  These are likely due to residual messages that were
123             # sent before disabling the member, but took a while to bounce.
124             syslog('bounce', '%s: %s residual bounce received',
125                    self.internal_name(), member)
126             return
127         elif info.date == day:
128             # We've already scored any bounces for this day, so ignore it.
129             syslog('bounce', '%s: %s already scored a bounce for date %s',
130                    self.internal_name(), member,
131                    time.strftime('%d-%b-%Y', day + (0,0,0,0,1,0)))
132             # Continue to check phase below
133         else:
134             # See if this member's bounce information is stale.
135             now = Utils.midnight(day)
136             lastbounce = Utils.midnight(info.date)
137             if lastbounce + self.bounce_info_stale_after < now:
138                 # Information is stale, so simply reset it
139                 info.reset(weight, day, self.bounce_you_are_disabled_warnings)
140                 syslog('bounce', '%s: %s has stale bounce info, resetting',
141                        self.internal_name(), member)
142             else:
143                 # Nope, the information isn't stale, so add to the bounce
144                 # score and take any necessary action.
145                 info.score += weight
146                 info.date = day
147                 syslog('bounce', '%s: %s current bounce score: %s',
148                        self.internal_name(), member, info.score)
149             # Continue to the check phase below
150         #
151         # Now that we've adjusted the bounce score for this bounce, let's
152         # check to see if the disable-by-bounce threshold has been reached.
153         if info.score >= self.bounce_score_threshold:
154             if mm_cfg.VERP_PROBES:
155                 syslog('bounce',
156                    'sending %s list probe to: %s (score %s >= %s)',
157                    self.internal_name(), member, info.score,
158                    self.bounce_score_threshold)
159                 self.sendProbe(member, msg)
160                 info.reset(0, info.date, info.noticesleft)
161             else:
162                 self.disableBouncingMember(member, info, msg)
163
164     def disableBouncingMember(self, member, info, msg):
165         # Initialize their confirmation cookie.  If we do it when we get the
166         # first bounce, it'll expire by the time we get the disabling bounce.
167         cookie = self.pend_new(Pending.RE_ENABLE, self.internal_name(), member)
168         info.cookie = cookie
169         # Disable them
170         if mm_cfg.VERP_PROBES:
171             syslog('bounce', '%s: %s disabling due to probe bounce received',
172                    self.internal_name(), member)
173         else:
174             syslog('bounce', '%s: %s disabling due to bounce score %s >= %s',
175                    self.internal_name(), member,
176                    info.score, self.bounce_score_threshold)
177         self.setDeliveryStatus(member, MemberAdaptor.BYBOUNCE)
178         self.sendNextNotification(member)
179         if self.bounce_notify_owner_on_disable:
180             self.__sendAdminBounceNotice(member, msg)
181
182     def __sendAdminBounceNotice(self, member, msg):
183         # BAW: This is a bit kludgey, but we're not providing as much
184         # information in the new admin bounce notices as we used to (some of
185         # it was of dubious value).  However, we'll provide empty, strange, or
186         # meaningless strings for the unused %()s fields so that the language
187         # translators don't have to provide new templates.
188         siteowner = Utils.get_site_email(self.host_name)
189         text = Utils.maketext(
190             'bounce.txt',
191             {'listname' : self.real_name,
192              'addr'     : member,
193              'negative' : '',
194              'did'      : _('disabled'),
195              'but'      : '',
196              'reenable' : '',
197              'owneraddr': siteowner,
198              }, mlist=self)
199         subject = _('Bounce action notification')
200         umsg = Message.UserNotification(self.GetOwnerEmail(),
201                                         siteowner, subject,
202                                         lang=self.preferred_language)
203         # BAW: Be sure you set the type before trying to attach, or you'll get
204         # a MultipartConversionError.
205         umsg.set_type('multipart/mixed')
206         umsg.attach(
207             MIMEText(text, _charset=Utils.GetCharSet(self.preferred_language)))
208         if isinstance(msg, StringType):
209             umsg.attach(MIMEText(msg))
210         else:
211             umsg.attach(MIMEMessage(msg))
212         umsg.send(self)
213
214     def sendNextNotification(self, member):
215         info = self.getBounceInfo(member)
216         if info is None:
217             return
218         reason = self.getDeliveryStatus(member)
219         if info.noticesleft <= 0:
220             # BAW: Remove them now, with a notification message
221             self.ApprovedDeleteMember(
222                 member, 'disabled address',
223                 admin_notif=self.bounce_notify_owner_on_removal,
224                 userack=1)
225             # Expunge the pending cookie for the user.  We throw away the
226             # returned data.
227             self.pend_confirm(info.cookie)
228             if reason == MemberAdaptor.BYBOUNCE:
229                 syslog('bounce', '%s: %s deleted after exhausting notices',
230                        self.internal_name(), member)
231             syslog('subscribe', '%s: %s auto-unsubscribed [reason: %s]',
232                    self.internal_name(), member,
233                    {MemberAdaptor.BYBOUNCE: 'BYBOUNCE',
234                     MemberAdaptor.BYUSER: 'BYUSER',
235                     MemberAdaptor.BYADMIN: 'BYADMIN',
236                     MemberAdaptor.UNKNOWN: 'UNKNOWN'}.get(
237                 reason, 'invalid value'))
238             return
239         # Send the next notification
240         confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1),
241                                 info.cookie)
242         optionsurl = self.GetOptionsURL(member, absolute=1)
243         reqaddr = self.GetRequestEmail()
244         lang = self.getMemberLanguage(member)
245         txtreason = REASONS.get(reason)
246         if txtreason is None:
247             txtreason = _('for unknown reasons')
248         else:
249             txtreason = _(txtreason)
250         # Give a little bit more detail on bounce disables
251         if reason == MemberAdaptor.BYBOUNCE:
252             date = time.strftime('%d-%b-%Y',
253                                  time.localtime(Utils.midnight(info.date)))
254             extra = _(' The last bounce received from you was dated %(date)s')
255             txtreason += extra
256         text = Utils.maketext(
257             'disabled.txt',
258             {'listname'   : self.real_name,
259              'noticesleft': info.noticesleft,
260              'confirmurl' : confirmurl,
261              'optionsurl' : optionsurl,
262              'password'   : self.getMemberPassword(member),
263              'owneraddr'  : self.GetOwnerEmail(),
264              'reason'     : txtreason,
265              }, lang=lang, mlist=self)
266         msg = Message.UserNotification(member, reqaddr, text=text, lang=lang)
267         # BAW: See the comment in MailList.py ChangeMemberAddress() for why we
268         # set the Subject this way.
269         del msg['subject']
270         msg['Subject'] = 'confirm ' + info.cookie
271         msg.send(self)
272         info.noticesleft -= 1
273         info.lastnotice = time.localtime()[:3]
274
275     def BounceMessage(self, msg, msgdata, e=None):
276         # Bounce a message back to the sender, with an error message if
277         # provided in the exception argument.
278         sender = msg.get_sender()
279         subject = msg.get('subject', _('(no subject)'))
280         subject = Utils.oneline(subject,
281                                 Utils.GetCharSet(self.preferred_language))
282         if e is None:
283             notice = _('[No bounce details are available]')
284         else:
285             notice = _(e.notice())
286         # Currently we always craft bounces as MIME messages.
287         bmsg = Message.UserNotification(msg.get_sender(),
288                                         self.GetOwnerEmail(),
289                                         subject,
290                                         lang=self.preferred_language)
291         # BAW: Be sure you set the type before trying to attach, or you'll get
292         # a MultipartConversionError.
293         bmsg.set_type('multipart/mixed')
294         txt = MIMEText(notice,
295                        _charset=Utils.GetCharSet(self.preferred_language))
296         bmsg.attach(txt)
297         bmsg.attach(MIMEMessage(msg))
298         bmsg.send(self)