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