# Copyright (C) 1998-2018 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 """\
<bounce info for member %(member)s
current score: %(score)s
last bounce date: %(date)s
email notices left: %(noticesleft)s
last notice date: %(lastnotice)s
confirmation cookie: %(cookie)s
>""" % 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):
global _
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)