1# Copyright (C) 1998-2018 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
20import sys
21import time
22from types import StringType
23
24from email.MIMEText import MIMEText
25from email.MIMEMessage import MIMEMessage
26
27from Mailman import mm_cfg
28from Mailman import Utils
29from Mailman import Message
30from Mailman import MemberAdaptor
31from Mailman import Pending
32from Mailman.Errors import MMUnknownListError
33from Mailman.Logging.Syslog import syslog
34from Mailman import i18n
35
36EMPTYSTRING = ''
37
38# This constant is supposed to represent the day containing the first midnight
39# after the epoch.  We'll add (0,)*6 to this tuple to get a value appropriate
40# for time.mktime().
41ZEROHOUR_PLUSONEDAY = time.localtime(mm_cfg.days(1))[:3]
42
43def D_(s): return s
44_ = D_
45
46REASONS = {MemberAdaptor.BYBOUNCE: _('due to excessive bounces'),
47           MemberAdaptor.BYUSER: _('by yourself'),
48           MemberAdaptor.BYADMIN: _('by the list administrator'),
49           MemberAdaptor.UNKNOWN: _('for unknown reasons'),
50           }
51
52_ = i18n._
53
54
55
56class _BounceInfo:
57    def __init__(self, member, score, date, noticesleft):
58        self.member = member
59        self.cookie = None
60        self.reset(score, date, noticesleft)
61
62    def reset(self, score, date, noticesleft):
63        self.score = score
64        self.date = date
65        self.noticesleft = noticesleft
66        self.lastnotice = ZEROHOUR_PLUSONEDAY
67
68    def __repr__(self):
69        # For debugging
70        return """\
71<bounce info for member %(member)s
72        current score: %(score)s
73        last bounce date: %(date)s
74        email notices left: %(noticesleft)s
75        last notice date: %(lastnotice)s
76        confirmation cookie: %(cookie)s
77        >""" % self.__dict__
78
79
80
81class Bouncer:
82    def InitVars(self):
83        # Configurable...
84        self.bounce_processing = mm_cfg.DEFAULT_BOUNCE_PROCESSING
85        self.bounce_score_threshold = mm_cfg.DEFAULT_BOUNCE_SCORE_THRESHOLD
86        self.bounce_info_stale_after = mm_cfg.DEFAULT_BOUNCE_INFO_STALE_AFTER
87        self.bounce_you_are_disabled_warnings = \
88            mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS
89        self.bounce_you_are_disabled_warnings_interval = \
90            mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS_INTERVAL
91        self.bounce_unrecognized_goes_to_list_owner = \
92            mm_cfg.DEFAULT_BOUNCE_UNRECOGNIZED_GOES_TO_LIST_OWNER
93        self.bounce_notify_owner_on_bounce_increment = \
94            mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_BOUNCE_INCREMENT
95        self.bounce_notify_owner_on_disable = \
96            mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_DISABLE
97        self.bounce_notify_owner_on_removal = \
98            mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL
99        # Not configurable...
100        #
101        # This holds legacy member related information.  It's keyed by the
102        # member address, and the value is an object containing the bounce
103        # score, the date of the last received bounce, and a count of the
104        # notifications left to send.
105        self.bounce_info = {}
106        # New style delivery status
107        self.delivery_status = {}
108
109    def registerBounce(self, member, msg, weight=1.0, day=None, sibling=False):
110        if not self.isMember(member):
111            # check regular_include_lists, only one level
112            if not self.regular_include_lists or sibling:
113                return
114            from Mailman.MailList import MailList
115            for listaddr in self.regular_include_lists:
116                listname, hostname = listaddr.split('@')
117                listname = listname.lower()
118                if listname == self.internal_name():
119                    syslog('error',
120                           'Bouncer: %s: Include list self reference',
121                           listname)
122                    continue
123                try:
124                    siblist = None
125                    try:
126                        siblist = MailList(listname)
127                    except MMUnknownListError:
128                        syslog('error',
129                               'Bouncer: %s: Include list "%s" not found.',
130                               self.real_name,
131                               listname)
132                        continue
133                    siblist.registerBounce(member, msg, weight, day,
134                                           sibling=True)
135                    siblist.Save()
136                finally:
137                    if siblist and siblist.Locked():
138                        siblist.Unlock()
139            return
140        info = self.getBounceInfo(member)
141        first_today = True
142        if day is None:
143            # Use today's date
144            day = time.localtime()[:3]
145        if not isinstance(info, _BounceInfo):
146            # This is the first bounce we've seen from this member
147            info = _BounceInfo(member, weight, day,
148                               self.bounce_you_are_disabled_warnings)
149            # setBounceInfo is now called below after check phase.
150            syslog('bounce', '%s: %s bounce score: %s', self.internal_name(),
151                   member, info.score)
152            # Continue to the check phase below
153        elif self.getDeliveryStatus(member) <> MemberAdaptor.ENABLED:
154            # The user is already disabled, so we can just ignore subsequent
155            # bounces.  These are likely due to residual messages that were
156            # sent before disabling the member, but took a while to bounce.
157            syslog('bounce', '%s: %s residual bounce received',
158                   self.internal_name(), member)
159            return
160        elif info.date == day:
161            # We've already scored any bounces for this day, so ignore it.
162            first_today = False
163            syslog('bounce', '%s: %s already scored a bounce for date %s',
164                   self.internal_name(), member,
165                   time.strftime('%d-%b-%Y', day + (0,0,0,0,1,0)))
166            # Continue to check phase below
167        else:
168            # See if this member's bounce information is stale.
169            now = Utils.midnight(day)
170            lastbounce = Utils.midnight(info.date)
171            if lastbounce + self.bounce_info_stale_after < now:
172                # Information is stale, so simply reset it
173                info.reset(weight, day, self.bounce_you_are_disabled_warnings)
174                syslog('bounce', '%s: %s has stale bounce info, resetting',
175                       self.internal_name(), member)
176            else:
177                # Nope, the information isn't stale, so add to the bounce
178                # score and take any necessary action.
179                info.score += weight
180                info.date = day
181                syslog('bounce', '%s: %s current bounce score: %s',
182                       self.internal_name(), member, info.score)
183            # Continue to the check phase below
184        #
185        # Now that we've adjusted the bounce score for this bounce, let's
186        # check to see if the disable-by-bounce threshold has been reached.
187        if info.score >= self.bounce_score_threshold:
188            if mm_cfg.VERP_PROBES:
189                syslog('bounce',
190                   'sending %s list probe to: %s (score %s >= %s)',
191                   self.internal_name(), member, info.score,
192                   self.bounce_score_threshold)
193                self.sendProbe(member, msg)
194                info.reset(0, info.date, info.noticesleft)
195            else:
196                self.disableBouncingMember(member, info, msg)
197        elif self.bounce_notify_owner_on_bounce_increment and first_today:
198            self.__sendAdminBounceNotice(member, msg,
199                                         did=_('bounce score incremented'))
200        # We've set/changed bounce info above.  We now need to tell the
201        # MemberAdaptor to set/update it.  We do it here in case the
202        # MemberAdaptor stores bounce info externally to the list object to
203        # be sure updated information is stored, but we have to be sure the
204        # member wasn't removed.
205        if self.isMember(member):
206            self.setBounceInfo(member, info)
207
208    def disableBouncingMember(self, member, info, msg):
209        # Initialize their confirmation cookie.  If we do it when we get the
210        # first bounce, it'll expire by the time we get the disabling bounce.
211        cookie = self.pend_new(Pending.RE_ENABLE, self.internal_name(), member)
212        info.cookie = cookie
213        # In case the MemberAdaptor stores bounce info externally to
214        # the list, we need to tell it to save the cookie
215        self.setBounceInfo(member, info)
216        # Disable them
217        if mm_cfg.VERP_PROBES:
218            syslog('bounce', '%s: %s disabling due to probe bounce received',
219                   self.internal_name(), member)
220        else:
221            syslog('bounce', '%s: %s disabling due to bounce score %s >= %s',
222                   self.internal_name(), member,
223                   info.score, self.bounce_score_threshold)
224        self.setDeliveryStatus(member, MemberAdaptor.BYBOUNCE)
225        self.sendNextNotification(member)
226        if self.bounce_notify_owner_on_disable:
227            self.__sendAdminBounceNotice(member, msg)
228
229    def __sendAdminBounceNotice(self, member, msg, did=None):
230        # BAW: This is a bit kludgey, but we're not providing as much
231        # information in the new admin bounce notices as we used to (some of
232        # it was of dubious value).  However, we'll provide empty, strange, or
233        # meaningless strings for the unused %()s fields so that the language
234        # translators don't have to provide new templates.
235        if did is None:
236            did = _('disabled')
237        siteowner = Utils.get_site_email(self.host_name)
238        text = Utils.maketext(
239            'bounce.txt',
240            {'listname' : self.real_name,
241             'addr'     : member,
242             'negative' : '',
243             'did'      : did,
244             'but'      : '',
245             'reenable' : '',
246             'owneraddr': siteowner,
247             }, mlist=self)
248        subject = _('Bounce action notification')
249        umsg = Message.UserNotification(self.GetOwnerEmail(),
250                                        siteowner, subject,
251                                        lang=self.preferred_language)
252        # BAW: Be sure you set the type before trying to attach, or you'll get
253        # a MultipartConversionError.
254        umsg.set_type('multipart/mixed')
255        umsg.attach(
256            MIMEText(text, _charset=Utils.GetCharSet(self.preferred_language)))
257        if isinstance(msg, StringType):
258            umsg.attach(MIMEText(msg))
259        else:
260            umsg.attach(MIMEMessage(msg))
261        umsg.send(self)
262
263    def sendNextNotification(self, member):
264        global _
265        info = self.getBounceInfo(member)
266        if info is None:
267            return
268        reason = self.getDeliveryStatus(member)
269        if info.noticesleft <= 0:
270            # BAW: Remove them now, with a notification message
271            _ = D_
272            self.ApprovedDeleteMember(
273                member, _('disabled address'),
274                admin_notif=self.bounce_notify_owner_on_removal,
275                userack=1)
276            _ = i18n._
277            # Expunge the pending cookie for the user.  We throw away the
278            # returned data.
279            self.pend_confirm(info.cookie)
280            if reason == MemberAdaptor.BYBOUNCE:
281                syslog('bounce', '%s: %s deleted after exhausting notices',
282                       self.internal_name(), member)
283            syslog('subscribe', '%s: %s auto-unsubscribed [reason: %s]',
284                   self.internal_name(), member,
285                   {MemberAdaptor.BYBOUNCE: 'BYBOUNCE',
286                    MemberAdaptor.BYUSER: 'BYUSER',
287                    MemberAdaptor.BYADMIN: 'BYADMIN',
288                    MemberAdaptor.UNKNOWN: 'UNKNOWN'}.get(
289                reason, 'invalid value'))
290            return
291        # Send the next notification
292        confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1),
293                                info.cookie)
294        optionsurl = self.GetOptionsURL(member, absolute=1)
295        reqaddr = self.GetRequestEmail()
296        lang = self.getMemberLanguage(member)
297        txtreason = REASONS.get(reason)
298        if txtreason is None:
299            txtreason = _('for unknown reasons')
300        else:
301            txtreason = _(txtreason)
302        # Give a little bit more detail on bounce disables
303        if reason == MemberAdaptor.BYBOUNCE:
304            date = time.strftime('%d-%b-%Y',
305                                 time.localtime(Utils.midnight(info.date)))
306            extra = _(' The last bounce received from you was dated %(date)s')
307            txtreason += extra
308        text = Utils.maketext(
309            'disabled.txt',
310            {'listname'   : self.real_name,
311             'noticesleft': info.noticesleft,
312             'confirmurl' : confirmurl,
313             'optionsurl' : optionsurl,
314             'password'   : self.getMemberPassword(member),
315             'owneraddr'  : self.GetOwnerEmail(),
316             'reason'     : txtreason,
317             }, lang=lang, mlist=self)
318        msg = Message.UserNotification(member, reqaddr, text=text, lang=lang)
319        # BAW: See the comment in MailList.py ChangeMemberAddress() for why we
320        # set the Subject this way.
321        del msg['subject']
322        msg['Subject'] = 'confirm ' + info.cookie
323        # Send without Precedence: bulk.  Bug #808821.
324        msg.send(self, noprecedence=True)
325        info.noticesleft -= 1
326        info.lastnotice = time.localtime()[:3]
327        # In case the MemberAdaptor stores bounce info externally to
328        # the list, we need to tell it to update
329        self.setBounceInfo(member, info)
330
331    def BounceMessage(self, msg, msgdata, e=None):
332        # Bounce a message back to the sender, with an error message if
333        # provided in the exception argument.
334        sender = msg.get_sender()
335        subject = msg.get('subject', _('(no subject)'))
336        subject = Utils.oneline(subject,
337                                Utils.GetCharSet(self.preferred_language))
338        if e is None:
339            notice = _('[No bounce details are available]')
340        else:
341            notice = _(e.notice())
342        # Currently we always craft bounces as MIME messages.
343        bmsg = Message.UserNotification(msg.get_sender(),
344                                        self.GetOwnerEmail(),
345                                        subject,
346                                        lang=self.preferred_language)
347        # BAW: Be sure you set the type before trying to attach, or you'll get
348        # a MultipartConversionError.
349        bmsg.set_type('multipart/mixed')
350        txt = MIMEText(notice,
351                       _charset=Utils.GetCharSet(self.preferred_language))
352        bmsg.attach(txt)
353        bmsg.attach(MIMEMessage(msg))
354        bmsg.send(self)
355