# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # # GNU Mailman 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 3 of the License, or (at your option) # any later version. # # GNU Mailman 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 # GNU Mailman. If not, see . """Handle delivery bounces.""" import sys import time import logging from email.MIMEMessage import MIMEMessage from email.MIMEText import MIMEText from mailman import Defaults from mailman import Message from mailman import Utils from mailman import i18n from mailman.configuration import config from mailman.interfaces import DeliveryStatus 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(60 * 60 * 24)[:3] def _(s): return s REASONS = { DeliveryStatus.by_bounces : _('due to excessive bounces'), DeliveryStatus.by_user : _('by yourself'), DeliveryStatus.by_moderator : _('by the list administrator'), DeliveryStatus.unknown : _('for unknown reasons'), } _ = i18n._ log = logging.getLogger('mailman.bounce') slog = logging.getLogger('mailman.subscribe') 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 registerBounce(self, member, msg, weight=1.0, day=None): if not self.isMember(member): return info = self.getBounceInfo(member) 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) self.setBounceInfo(member, info) log.info('%s: %s bounce score: %s', self.internal_name(), member, info.score) # Continue to the check phase below elif self.getDeliveryStatus(member) <> DeliveryStatus.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. log.info('%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. log.info('%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) log.info('%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 log.info('%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 config.VERP_PROBES: log.info('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) 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 # Disable them if config.VERP_PROBES: log.info('%s: %s disabling due to probe bounce received', self.internal_name(), member) else: log.info('%s: %s disabling due to bounce score %s >= %s', self.internal_name(), member, info.score, self.bounce_score_threshold) self.setDeliveryStatus(member, DeliveryStatus.by_bounces) self.sendNextNotification(member) if self.bounce_notify_owner_on_disable: self.__sendAdminBounceNotice(member, msg) def __sendAdminBounceNotice(self, member, msg): # 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. text = Utils.maketext( 'bounce.txt', {'listname' : self.real_name, 'addr' : member, 'negative' : '', 'did' : _('disabled'), 'but' : '', 'reenable' : '', 'owneraddr': self.no_reply_address, }, mlist=self) subject = _('Bounce action notification') umsg = Message.UserNotification(self.GetOwnerEmail(), self.no_reply_address, 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, str): 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 self.ApprovedDeleteMember( member, 'disabled address', admin_notif=self.bounce_notify_owner_on_removal, userack=1) # Expunge the pending cookie for the user. We throw away the # returned data. self.pend_confirm(info.cookie) if reason == DeliveryStatus.by_bounces: log.info('%s: %s deleted after exhausting notices', self.internal_name(), member) slog.info('%s: %s auto-unsubscribed [reason: %s]', self.internal_name(), member, {DeliveryStatus.by_bounces: 'BYBOUNCE', DeliveryStatus.by_user: 'BYUSER', DeliveryStatus.by_moderator: 'BYADMIN', DeliveryStatus.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 == DeliveryStatus.by_bounces: 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 msg.send(self) info.noticesleft -= 1 info.lastnotice = time.localtime()[:3]