# Copyright (C) 1998-2014 by the Free Software Foundation, Inc. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. """Handle delivery bounces.""" import sys import time from types import StringType from email.MIMEText import MIMEText from email.MIMEMessage import MIMEMessage from Mailman import mm_cfg from Mailman import Utils from Mailman import Message from Mailman import MemberAdaptor from Mailman import Pending from Mailman.Errors import MMUnknownListError from Mailman.Logging.Syslog import syslog from Mailman import i18n EMPTYSTRING = '' # This constant is supposed to represent the day containing the first midnight # after the epoch. We'll add (0,)*6 to this tuple to get a value appropriate # for time.mktime(). ZEROHOUR_PLUSONEDAY = time.localtime(mm_cfg.days(1))[:3] def D_(s): return s _ = D_ REASONS = {MemberAdaptor.BYBOUNCE: _('due to excessive bounces'), MemberAdaptor.BYUSER: _('by yourself'), MemberAdaptor.BYADMIN: _('by the list administrator'), MemberAdaptor.UNKNOWN: _('for unknown reasons'), } _ = i18n._ class _BounceInfo: def __init__(self, member, score, date, noticesleft): self.member = member self.cookie = None self.reset(score, date, noticesleft) def reset(self, score, date, noticesleft): self.score = score self.date = date self.noticesleft = noticesleft self.lastnotice = ZEROHOUR_PLUSONEDAY def __repr__(self): # For debugging return """\ """ % self.__dict__ class Bouncer: def InitVars(self): # Configurable... self.bounce_processing = mm_cfg.DEFAULT_BOUNCE_PROCESSING self.bounce_score_threshold = mm_cfg.DEFAULT_BOUNCE_SCORE_THRESHOLD self.bounce_info_stale_after = mm_cfg.DEFAULT_BOUNCE_INFO_STALE_AFTER self.bounce_you_are_disabled_warnings = \ mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS self.bounce_you_are_disabled_warnings_interval = \ mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS_INTERVAL self.bounce_unrecognized_goes_to_list_owner = \ mm_cfg.DEFAULT_BOUNCE_UNRECOGNIZED_GOES_TO_LIST_OWNER self.bounce_notify_owner_on_bounce_increment = \ mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_BOUNCE_INCREMENT self.bounce_notify_owner_on_disable = \ mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_DISABLE self.bounce_notify_owner_on_removal = \ mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL # Not configurable... # # This holds legacy member related information. It's keyed by the # member address, and the value is an object containing the bounce # score, the date of the last received bounce, and a count of the # notifications left to send. self.bounce_info = {} # New style delivery status self.delivery_status = {} def registerBounce(self, member, msg, weight=1.0, day=None, sibling=False): if not self.isMember(member): # check regular_include_lists, only one level if not self.regular_include_lists or sibling: return from Mailman.MailList import MailList for listaddr in self.regular_include_lists: listname, hostname = listaddr.split('@') listname = listname.lower() if listname == self.internal_name(): syslog('error', 'Bouncer: %s: Include list self reference', listname) continue try: siblist = None try: siblist = MailList(listname) except MMUnknownListError: syslog('error', 'Bouncer: %s: Include list "%s" not found.', self.real_name, listname) continue siblist.registerBounce(member, msg, weight, day, sibling=True) siblist.Save() finally: if siblist and siblist.Locked(): siblist.Unlock() return info = self.getBounceInfo(member) first_today = True if day is None: # Use today's date day = time.localtime()[:3] if not isinstance(info, _BounceInfo): # This is the first bounce we've seen from this member info = _BounceInfo(member, weight, day, self.bounce_you_are_disabled_warnings) # setBounceInfo is now called below after check phase. syslog('bounce', '%s: %s bounce score: %s', self.internal_name(), member, info.score) # Continue to the check phase below elif self.getDeliveryStatus(member) <> MemberAdaptor.ENABLED: # The user is already disabled, so we can just ignore subsequent # bounces. These are likely due to residual messages that were # sent before disabling the member, but took a while to bounce. syslog('bounce', '%s: %s residual bounce received', self.internal_name(), member) return elif info.date == day: # We've already scored any bounces for this day, so ignore it. first_today = False syslog('bounce', '%s: %s already scored a bounce for date %s', self.internal_name(), member, time.strftime('%d-%b-%Y', day + (0,0,0,0,1,0))) # Continue to check phase below else: # See if this member's bounce information is stale. now = Utils.midnight(day) lastbounce = Utils.midnight(info.date) if lastbounce + self.bounce_info_stale_after < now: # Information is stale, so simply reset it info.reset(weight, day, self.bounce_you_are_disabled_warnings) syslog('bounce', '%s: %s has stale bounce info, resetting', self.internal_name(), member) else: # Nope, the information isn't stale, so add to the bounce # score and take any necessary action. info.score += weight info.date = day syslog('bounce', '%s: %s current bounce score: %s', self.internal_name(), member, info.score) # Continue to the check phase below # # Now that we've adjusted the bounce score for this bounce, let's # check to see if the disable-by-bounce threshold has been reached. if info.score >= self.bounce_score_threshold: if mm_cfg.VERP_PROBES: syslog('bounce', 'sending %s list probe to: %s (score %s >= %s)', self.internal_name(), member, info.score, self.bounce_score_threshold) self.sendProbe(member, msg) info.reset(0, info.date, info.noticesleft) else: self.disableBouncingMember(member, info, msg) elif self.bounce_notify_owner_on_bounce_increment and first_today: self.__sendAdminBounceNotice(member, msg, did=_('bounce score incremented')) # We've set/changed bounce info above. We now need to tell the # MemberAdaptor to set/update it. We do it here in case the # MemberAdaptor stores bounce info externally to the list object to # be sure updated information is stored, but we have to be sure the # member wasn't removed. if self.isMember(member): self.setBounceInfo(member, info) def disableBouncingMember(self, member, info, msg): # Initialize their confirmation cookie. If we do it when we get the # first bounce, it'll expire by the time we get the disabling bounce. cookie = self.pend_new(Pending.RE_ENABLE, self.internal_name(), member) info.cookie = cookie # In case the MemberAdaptor stores bounce info externally to # the list, we need to tell it to save the cookie self.setBounceInfo(member, info) # Disable them if mm_cfg.VERP_PROBES: syslog('bounce', '%s: %s disabling due to probe bounce received', self.internal_name(), member) else: syslog('bounce', '%s: %s disabling due to bounce score %s >= %s', self.internal_name(), member, info.score, self.bounce_score_threshold) self.setDeliveryStatus(member, MemberAdaptor.BYBOUNCE) self.sendNextNotification(member) if self.bounce_notify_owner_on_disable: self.__sendAdminBounceNotice(member, msg) def __sendAdminBounceNotice(self, member, msg, did=_('disabled')): # BAW: This is a bit kludgey, but we're not providing as much # information in the new admin bounce notices as we used to (some of # it was of dubious value). However, we'll provide empty, strange, or # meaningless strings for the unused %()s fields so that the language # translators don't have to provide new templates. siteowner = Utils.get_site_email(self.host_name) text = Utils.maketext( 'bounce.txt', {'listname' : self.real_name, 'addr' : member, 'negative' : '', 'did' : did, 'but' : '', 'reenable' : '', 'owneraddr': siteowner, }, mlist=self) subject = _('Bounce action notification') umsg = Message.UserNotification(self.GetOwnerEmail(), siteowner, subject, lang=self.preferred_language) # BAW: Be sure you set the type before trying to attach, or you'll get # a MultipartConversionError. umsg.set_type('multipart/mixed') umsg.attach( MIMEText(text, _charset=Utils.GetCharSet(self.preferred_language))) if isinstance(msg, StringType): umsg.attach(MIMEText(msg)) else: umsg.attach(MIMEMessage(msg)) umsg.send(self) def sendNextNotification(self, member): info = self.getBounceInfo(member) if info is None: return reason = self.getDeliveryStatus(member) if info.noticesleft <= 0: # BAW: Remove them now, with a notification message _ = D_ self.ApprovedDeleteMember( member, _('disabled address'), admin_notif=self.bounce_notify_owner_on_removal, userack=1) _ = i18n._ # Expunge the pending cookie for the user. We throw away the # returned data. self.pend_confirm(info.cookie) if reason == MemberAdaptor.BYBOUNCE: syslog('bounce', '%s: %s deleted after exhausting notices', self.internal_name(), member) syslog('subscribe', '%s: %s auto-unsubscribed [reason: %s]', self.internal_name(), member, {MemberAdaptor.BYBOUNCE: 'BYBOUNCE', MemberAdaptor.BYUSER: 'BYUSER', MemberAdaptor.BYADMIN: 'BYADMIN', MemberAdaptor.UNKNOWN: 'UNKNOWN'}.get( reason, 'invalid value')) return # Send the next notification confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), info.cookie) optionsurl = self.GetOptionsURL(member, absolute=1) reqaddr = self.GetRequestEmail() lang = self.getMemberLanguage(member) txtreason = REASONS.get(reason) if txtreason is None: txtreason = _('for unknown reasons') else: txtreason = _(txtreason) # Give a little bit more detail on bounce disables if reason == MemberAdaptor.BYBOUNCE: date = time.strftime('%d-%b-%Y', time.localtime(Utils.midnight(info.date))) extra = _(' The last bounce received from you was dated %(date)s') txtreason += extra text = Utils.maketext( 'disabled.txt', {'listname' : self.real_name, 'noticesleft': info.noticesleft, 'confirmurl' : confirmurl, 'optionsurl' : optionsurl, 'password' : self.getMemberPassword(member), 'owneraddr' : self.GetOwnerEmail(), 'reason' : txtreason, }, lang=lang, mlist=self) msg = Message.UserNotification(member, reqaddr, text=text, lang=lang) # BAW: See the comment in MailList.py ChangeMemberAddress() for why we # set the Subject this way. del msg['subject'] msg['Subject'] = 'confirm ' + info.cookie # Send without Precedence: bulk. Bug #808821. msg.send(self, noprecedence=True) info.noticesleft -= 1 info.lastnotice = time.localtime()[:3] # In case the MemberAdaptor stores bounce info externally to # the list, we need to tell it to update self.setBounceInfo(member, info) def BounceMessage(self, msg, msgdata, e=None): # Bounce a message back to the sender, with an error message if # provided in the exception argument. sender = msg.get_sender() subject = msg.get('subject', _('(no subject)')) subject = Utils.oneline(subject, Utils.GetCharSet(self.preferred_language)) if e is None: notice = _('[No bounce details are available]') else: notice = _(e.notice()) # Currently we always craft bounces as MIME messages. bmsg = Message.UserNotification(msg.get_sender(), self.GetOwnerEmail(), subject, lang=self.preferred_language) # BAW: Be sure you set the type before trying to attach, or you'll get # a MultipartConversionError. bmsg.set_type('multipart/mixed') txt = MIMEText(notice, _charset=Utils.GetCharSet(self.preferred_language)) bmsg.attach(txt) bmsg.attach(MIMEMessage(msg)) bmsg.send(self)