PageRenderTime 54ms CodeModel.GetById 23ms RepoModel.GetById 0ms app.codeStats 0ms

/tags/mailman-1_0b3/mailman/Mailman/Bouncer.py

#
Python | 331 lines | 310 code | 10 blank | 11 comment | 21 complexity | 501ec1a078b8fe9e53de71ed37eb92a0 MD5 | raw file
Possible License(s): GPL-2.0
  1. "Handle delivery bounce messages, doing filtering when list is set for it."
  2. __version__ = "$Revision: 467 $"
  3. # It's possible to get the mail-list senders address (list-admin) in the
  4. # bounce list. You probably don't want to have list mail sent to that
  5. # address anyway.
  6. import sys
  7. import time
  8. import regsub, string, regex
  9. import mm_utils, mm_cfg, mm_err
  10. class Bouncer:
  11. def InitVars(self):
  12. # Not configurable...
  13. self.bounce_info = {}
  14. # Configurable...
  15. self.bounce_processing = mm_cfg.DEFAULT_BOUNCE_PROCESSING
  16. self.minimum_removal_date = mm_cfg.DEFAULT_MINIMUM_REMOVAL_DATE
  17. self.minimum_post_count_before_bounce_action = \
  18. mm_cfg.DEFAULT_MINIMUM_POST_COUNT_BEFORE_BOUNCE_ACTION
  19. self.automatic_bounce_action = mm_cfg.DEFAULT_AUTOMATIC_BOUNCE_ACTION
  20. self.max_posts_between_bounces = \
  21. mm_cfg.DEFAULT_MAX_POSTS_BETWEEN_BOUNCES
  22. def GetConfigInfo(self):
  23. return [
  24. "Policies regarding systematic processing of bounce messages,"
  25. " to help automate recognition and handling of defunct"
  26. " addresses.",
  27. ('bounce_processing', mm_cfg.Toggle, ('No', 'Yes'), 0,
  28. 'Try to figure out error messages automatically? '),
  29. ('minimum_removal_date', mm_cfg.Number, 3, 0,
  30. 'Minimum number of days an address has been non-fatally '
  31. 'bad before we take action'),
  32. ('minimum_post_count_before_bounce_action', mm_cfg.Number, 3, 0,
  33. 'Minimum number of posts to the list since members first '
  34. 'bounce before we consider removing them from the list'),
  35. ('max_posts_between_bounces', mm_cfg.Number, 3, 0,
  36. "Maximum number of messages your list gets in an hour. "
  37. "(Yes, bounce detection finds this info useful)"),
  38. ('automatic_bounce_action', mm_cfg.Radio,
  39. ("Do nothing",
  40. "Disable and notify me",
  41. "Disable and DON'T notify me",
  42. "Remove and notify me"),
  43. 0, "Action when critical or excessive bounces are detected.")
  44. ]
  45. def ClearBounceInfo(self, email):
  46. email = string.lower(email)
  47. if self.bounce_info.has_key(email):
  48. del self.bounce_info[email]
  49. def RegisterBounce(self, email):
  50. report = "%s: %s - " % (self.real_name, email)
  51. bouncees = self.bounce_info.keys()
  52. this_dude = mm_utils.FindMatchingAddresses(email, bouncees)
  53. now = time.time()
  54. if not len(this_dude):
  55. # Time address went bad, post where address went bad,
  56. # What the last post ID was that we saw a bounce.
  57. self.bounce_info[string.lower(email)] = [now, self.post_id,
  58. self.post_id]
  59. self.LogMsg("bounce", report + "first")
  60. self.Save()
  61. return
  62. addr = string.lower(this_dude[0])
  63. inf = self.bounce_info[addr]
  64. difference = now - inf[0]
  65. if len(mm_utils.FindMatchingAddresses(addr, self.members)):
  66. if self.post_id - inf[2] > self.max_posts_between_bounces:
  67. # Stale entry that's now being restarted...
  68. # Should maybe keep track in see if people become stale entries
  69. # often...
  70. self.LogMsg("bounce",
  71. report + "first fresh")
  72. self.bounce_info[addr] = [now, self.post_id, self.post_id]
  73. return
  74. self.bounce_info[addr][2] = self.post_id
  75. if ((self.post_id - inf[1] >
  76. self.minimum_post_count_before_bounce_action)
  77. and difference > self.minimum_removal_date * 24 * 60 * 60):
  78. self.LogMsg("bounce", report + "exceeded limits")
  79. self.HandleBouncingAddress(addr)
  80. return
  81. else:
  82. post_count = (self.minimum_post_count_before_bounce_action -
  83. self.post_id - inf[1])
  84. if post_count < 0:
  85. post_count = 0
  86. remain = self.minimum_removal_date * 24 * 60 * 60 - difference
  87. self.LogMsg("bounce",
  88. report + ("%d more posts, %d more secs"
  89. % (post_count, remain)))
  90. self.Save()
  91. return
  92. elif len(mm_utils.FindMatchingAddresses(addr, self.digest_members)):
  93. if self.volume > inf[1]:
  94. self.LogMsg("bounce",
  95. "%s: first fresh (D)",
  96. self._internal_name)
  97. self.bounce_info[addr] = [now, self.volume, self.volume]
  98. return
  99. if difference > self.minimum_removal_date * 24 * 60 * 60:
  100. self.LogMsg("bounce", "exceeded limits (D)")
  101. self.HandleBouncingAddress(addr)
  102. return
  103. self.LogMsg("bounce",
  104. "digester lucked out")
  105. else:
  106. self.LogMsg("bounce",
  107. "%s: address %s not a member.",
  108. self._internal_name,
  109. addr)
  110. def HandleBouncingAddress(self, addr):
  111. """Disable or remove addr according to bounce_action setting."""
  112. if self.automatic_bounce_action == 0:
  113. return
  114. elif self.automatic_bounce_action == 1:
  115. succeeded = self.DisableBouncingAddress(addr)
  116. did = "disabled"
  117. send = 1
  118. elif self.automatic_bounce_action == 2:
  119. succeeded = self.DisableBouncingAddress(addr)
  120. did = "disabled"
  121. send = 0
  122. elif self.automatic_bounce_action == 3:
  123. succeeded = self.RemoveBouncingAddress(addr)
  124. did = "removed"
  125. send = 1
  126. if send:
  127. if succeeded != 1:
  128. negative="not "
  129. recipient = mm_cfg.MAILMAN_OWNER
  130. else:
  131. negative=""
  132. recipient = self.GetAdminEmail()
  133. text = ("This is a mailman maillist administrator notice.\n"
  134. "\n\tMaillist:\t%s\n"
  135. "\tMember:\t\t%s\n"
  136. "\tAction:\t\tSubscription %s%s.\n"
  137. "\tReason:\t\tExcessive or fatal bounces.\n"
  138. % (self.real_name, addr, negative, did))
  139. if succeeded != 1:
  140. text = text + "\tBUT:\t\t%s\n\n" % succeeded
  141. else:
  142. text = text + "\n"
  143. if did == "disabled" and succeeded == 1:
  144. text = text + (
  145. "You can reenable their subscription by visiting "
  146. "their options page\n"
  147. "(via %s) and using your\n"
  148. "list admin password to authorize the option change.\n\n"
  149. % self.GetScriptURL('listinfo'))
  150. text = text + ("Questions? Contact the mailman site admin,\n%s"
  151. % mm_cfg.MAILMAN_OWNER)
  152. if negative:
  153. negative = string.upper(negative)
  154. self.SendTextToUser(subject = ("%s member %s %s%s due to bounces"
  155. % (self.real_name, addr,
  156. negative, did)),
  157. recipient = recipient,
  158. sender = mm_cfg.MAILMAN_OWNER,
  159. add_headers = ["Errors-To: %s"
  160. % mm_cfg.MAILMAN_OWNER],
  161. text = text)
  162. def DisableBouncingAddress(self, addr):
  163. if not self.IsMember(addr):
  164. reason = "User not found."
  165. self.LogMsg("bounce", "%s: NOT disabled %s: %s",
  166. self.real_name, addr, reason)
  167. return reason
  168. try:
  169. self.SetUserOption(addr, mm_cfg.DisableDelivery, 1)
  170. self.LogMsg("bounce", "%s: disabled %s", self.real_name, addr)
  171. self.Save()
  172. return 1
  173. except mm_err.MMNoSuchUserError:
  174. self.LogMsg("bounce", "%s: NOT disabled %s: %s",
  175. self.real_name, addr, mm_err.MMNoSuchUserError)
  176. self.ClearBounceInfo(addr)
  177. self.Save()
  178. return mm_err.MMNoSuchUserError
  179. def RemoveBouncingAddress(self, addr):
  180. if not self.IsMember(addr):
  181. reason = "User not found."
  182. self.LogMsg("bounce", "%s: NOT removed %s: %s",
  183. self.real_name, addr, reason)
  184. return reason
  185. try:
  186. self.DeleteMember(addr, "bouncing addr")
  187. self.LogMsg("bounce", "%s: removed %s", self.real_name, addr)
  188. self.Save()
  189. return 1
  190. except mm_err.MMNoSuchUserError:
  191. self.LogMsg("bounce", "%s: NOT removed %s: %s",
  192. self.real_name, addr, mm_err.MMNoSuchUserError)
  193. self.ClearBounceInfo(addr)
  194. self.Save()
  195. return mm_err.MMNoSuchUserError
  196. # Return 0 if we couldn't make any sense of it, 1 if we handled it.
  197. def ScanMessage(self, msg):
  198. ## realname, who_from = msg.getaddr('from')
  199. ## who_info = string.lower(who_from)
  200. candidates = []
  201. who_info = string.lower(msg.GetSender())
  202. at_index = string.find(who_info, '@')
  203. if at_index != -1:
  204. who_from = who_info[:at_index]
  205. remote_host = who_info[at_index+1:]
  206. else:
  207. who_from = who_info
  208. remote_host = self.host_name
  209. if not who_from in ['mailer-daemon', 'postmaster', 'orphanage',
  210. 'postoffice', 'ucx_smtp', 'a2']:
  211. return 0
  212. mime_info = msg.getheader('content-type')
  213. boundry = None
  214. if mime_info:
  215. mime_info_parts = regsub.splitx(
  216. mime_info, '[Bb][Oo][Uu][Nn][Dd][Aa][Rr][Yy]="[^"]+"')
  217. if len(mime_info_parts) > 1:
  218. boundry = regsub.splitx(mime_info_parts[1],
  219. '"[^"]+"')[1][1:-1]
  220. if boundry:
  221. relevant_text = string.split(msg.body, '--%s' % boundry)[1]
  222. else:
  223. # This looks strange, but at least 2 are going to be no-ops.
  224. relevant_text = regsub.split(msg.body,
  225. '^.*Message header follows.*$')[0]
  226. relevant_text = regsub.split(relevant_text,
  227. '^The text you sent follows:.*$')[0]
  228. relevant_text = regsub.split(
  229. relevant_text, '^Additional Message Information:.*$')[0]
  230. relevant_text = regsub.split(relevant_text,
  231. '^-+Your original message-+.*$')[0]
  232. BOUNCE = 1
  233. REMOVE = 2
  234. # Bounce patterns where it's simple to figure out the email addr.
  235. email_regexp = '<?[^ \t@<>]+@[^ \t@<>]+\.[^ \t<>.]+>?'
  236. simple_bounce_pats = (
  237. (regex.compile('.*451 %s.*' % email_regexp), BOUNCE),
  238. (regex.compile('.*554 %s.*' % email_regexp), BOUNCE),
  239. (regex.compile('.*552 %s.*' % email_regexp), BOUNCE),
  240. (regex.compile('.*501 %s.*' % email_regexp), BOUNCE),
  241. (regex.compile('.*553 %s.*' % email_regexp), BOUNCE),
  242. (regex.compile('.*550 %s.*' % email_regexp), REMOVE),
  243. (regex.compile('%s .bounced.*' % email_regexp), BOUNCE),
  244. (regex.compile('.*%s\.\.\. Deferred.*' % email_regexp), BOUNCE),
  245. (regex.compile('.*User %s not known.*' % email_regexp), REMOVE),
  246. (regex.compile('.*%s: User unknown.*' % email_regexp), REMOVE))
  247. # patterns we can't directly extract the email (special case these)
  248. messy_pattern_1 = regex.compile('^Recipient .*$')
  249. messy_pattern_2 = regex.compile('^Addressee: .*$')
  250. messy_pattern_3 = regex.compile('^User .* not listed.*$')
  251. messy_pattern_4 = regex.compile('^550 [^ ]+\.\.\. User unknown.*$')
  252. messy_pattern_5 = regex.compile('^User [^ ]+ is not defined.*$')
  253. messy_pattern_6 = regex.compile('^[ \t]*[^ ]+: User unknown.*$')
  254. messy_pattern_7 = regex.compile('^[^ ]+ - User currently disabled.*$')
  255. message_groked = 0
  256. for line in string.split(relevant_text, '\n'):
  257. for pattern, action in simple_bounce_pats:
  258. if pattern.match(line) <> -1:
  259. email = self.ExtractBouncingAddr(line)
  260. if action == REMOVE:
  261. candidates = candidates + string.split(email,',')
  262. message_groked = 1
  263. continue
  264. elif action == BOUNCE:
  265. emails = string.split(email,',')
  266. for email_addr in emails:
  267. self.RegisterBounce(email_addr)
  268. message_groked = 1
  269. continue
  270. else:
  271. message_groked = 1
  272. continue
  273. # Now for the special case messages that are harder to parse...
  274. if (messy_pattern_1.match(line) <> -1
  275. or messy_pattern_2.match(line) <> -1):
  276. username = string.split(line)[1]
  277. self.RegisterBounce('%s@%s' % (username, remote_host))
  278. message_groked = 1
  279. continue
  280. if (messy_pattern_3.match(line) <> -1
  281. or messy_pattern_4.match(line) <> -1
  282. or messy_pattern_5.match(line) <> -1):
  283. username = string.split(line)[1]
  284. candidates.append('%s@%s' % (username, remote_host))
  285. message_groked = 1
  286. continue
  287. if messy_pattern_6.match(line) <> -1:
  288. username = string.split(string.strip(line))[0][:-1]
  289. candidates.append('%s@%s' % (username, remote_host))
  290. message_groked = 1
  291. continue
  292. if messy_pattern_7.match(line) <> -1:
  293. username = string.split(string.strip(line))[0]
  294. candidates.append('%s@%s' % (username, remote_host))
  295. message_groked = 1
  296. continue
  297. did = []
  298. for i in candidates:
  299. el = string.find(i, "...")
  300. if el != -1: i = i[:el]
  301. if i not in did:
  302. self.HandleBouncingAddress(i)
  303. did.append(i)
  304. return message_groked
  305. def ExtractBouncingAddr(self, line):
  306. email = regsub.splitx(line, '[^ \t@<>]+@[^ \t@<>]+\.[^ \t<>.]+')[1]
  307. if email[0] == '<':
  308. # Remove what's within the angles.
  309. return regsub.splitx(email[1:], ">")[0]
  310. else:
  311. return email