# 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]