diff options
| author | Barry Warsaw | 2007-09-21 08:51:38 -0400 |
|---|---|---|
| committer | Barry Warsaw | 2007-09-21 08:51:38 -0400 |
| commit | 65c64773d910b3b2a3e2a9b9db4669e57170ece2 (patch) | |
| tree | 1bb742b2dc5898e569e19d8967e8d51a5d5c0352 | |
| parent | 892316be3c09eec4a1f8117bfd9eb44bba1c9117 (diff) | |
| download | mailman-65c64773d910b3b2a3e2a9b9db4669e57170ece2.tar.gz mailman-65c64773d910b3b2a3e2a9b9db4669e57170ece2.tar.zst mailman-65c64773d910b3b2a3e2a9b9db4669e57170ece2.zip | |
| -rw-r--r-- | Mailman/Bouncer.py | 25 | ||||
| -rw-r--r-- | Mailman/Handlers/CookHeaders.py | 3 | ||||
| -rw-r--r-- | Mailman/Handlers/Hold.py | 17 | ||||
| -rw-r--r-- | Mailman/Handlers/Replybot.py | 8 | ||||
| -rw-r--r-- | Mailman/Handlers/Scrubber.py | 6 | ||||
| -rw-r--r-- | Mailman/Handlers/ToDigest.py | 2 | ||||
| -rw-r--r-- | Mailman/MailList.py | 239 | ||||
| -rw-r--r-- | Mailman/Queue/CommandRunner.py | 3 | ||||
| -rw-r--r-- | Mailman/Utils.py | 28 | ||||
| -rw-r--r-- | Mailman/app/archiving.py | 36 | ||||
| -rw-r--r-- | Mailman/app/bounces.py | 163 | ||||
| -rw-r--r-- | Mailman/app/lifecycle.py | 2 | ||||
| -rw-r--r-- | Mailman/app/membership.py | 2 | ||||
| -rw-r--r-- | Mailman/app/replybot.py | 89 | ||||
| -rw-r--r-- | Mailman/database/listmanager.py | 30 | ||||
| -rw-r--r-- | Mailman/database/model/mailinglist.py | 48 | ||||
| -rw-r--r-- | Mailman/database/model/requests.py | 12 | ||||
| -rw-r--r-- | Mailman/docs/acknowledge.txt | 2 | ||||
| -rw-r--r-- | Mailman/docs/bounces.txt | 5 | ||||
| -rw-r--r-- | Mailman/docs/cook-headers.txt | 2 | ||||
| -rw-r--r-- | Mailman/docs/hold.txt | 2 | ||||
| -rw-r--r-- | Mailman/docs/listmanager.txt | 8 | ||||
| -rw-r--r-- | Mailman/docs/replybot.txt | 4 | ||||
| -rw-r--r-- | Mailman/docs/requests.txt | 2 | ||||
| -rw-r--r-- | Mailman/docs/scrubber.txt | 3 | ||||
| -rw-r--r-- | TODO.txt | 2 |
26 files changed, 424 insertions, 319 deletions
diff --git a/Mailman/Bouncer.py b/Mailman/Bouncer.py index 99093b334..f4a8ce90f 100644 --- a/Mailman/Bouncer.py +++ b/Mailman/Bouncer.py @@ -247,28 +247,3 @@ class Bouncer: msg.send(self) info.noticesleft -= 1 info.lastnotice = time.localtime()[:3] - - def bounce_message(self, msg, 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.owner_address, - 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) diff --git a/Mailman/Handlers/CookHeaders.py b/Mailman/Handlers/CookHeaders.py index 5634fa23e..82c87c20b 100644 --- a/Mailman/Handlers/CookHeaders.py +++ b/Mailman/Handlers/CookHeaders.py @@ -26,6 +26,7 @@ from email.Utils import parseaddr, formataddr, getaddresses from Mailman import Utils from Mailman import Version +from Mailman.app.archiving import get_base_archive_url from Mailman.configuration import config from Mailman.constants import ReplyToMunging from Mailman.i18n import _ @@ -207,7 +208,7 @@ def process(mlist, msg, msgdata): headers['List-Post'] = '<mailto:%s>' % mlist.posting_address # Add this header if we're archiving if mlist.archive: - archiveurl = mlist.GetBaseArchiveURL() + archiveurl = get_base_archive_url(mlist) if archiveurl.endswith('/'): archiveurl = archiveurl[:-1] headers['List-Archive'] = '<%s>' % archiveurl diff --git a/Mailman/Handlers/Hold.py b/Mailman/Handlers/Hold.py index 75f7b6386..2e6eeb4ad 100644 --- a/Mailman/Handlers/Hold.py +++ b/Mailman/Handlers/Hold.py @@ -43,7 +43,10 @@ from Mailman import Errors from Mailman import Message from Mailman import Utils from Mailman import i18n +from Mailman.app.bounces import ( + has_explicit_destination, has_matching_bounce_header) from Mailman.app.moderator import hold_message +from Mailman.app.replybot import autorespond_to_sender from Mailman.configuration import config from Mailman.interfaces import IPendable @@ -88,7 +91,7 @@ class Administrivia(Errors.HoldMessage): reason = _('Message may contain administrivia') def rejection_notice(self, mlist): - listurl = mlist.GetScriptURL('listinfo', absolute=1) + listurl = mlist.script_url('listinfo') request = mlist.request_address return _("""Please do *not* post administrative requests to the mailing list. If you wish to subscribe, visit $listurl or send a message with the @@ -171,7 +174,7 @@ def process(mlist, msg, msgdata): # Implicit destination? Note that message originating from the Usenet # side of the world should never be checked for implicit destination. if mlist.require_explicit_destination and \ - not mlist.HasExplicitDest(msg) and \ + not has_explicit_destination(mlist, msg) and \ not msgdata.get('fromusenet'): # then hold_for_approval(mlist, msg, msgdata, ImplicitDestination) @@ -179,7 +182,7 @@ def process(mlist, msg, msgdata): # # Suspicious headers? if mlist.bounce_matching_headers: - triggered = mlist.hasMatchingHeader(msg) + triggered = has_matching_bounce_header(mlist, msg) if triggered: # TBD: Darn - can't include the matching line for the admin # message because the info would also go to the sender @@ -239,7 +242,7 @@ def hold_for_approval(mlist, msg, msgdata, exc): 'reason' : _(reason), 'sender' : sender, 'subject' : usersubject, - 'admindb_url': mlist.GetScriptURL('admindb', absolute=1), + 'admindb_url': mlist.script_url('admindb'), } # We may want to send a notification to the original sender too fromusenet = msgdata.get('fromusenet') @@ -259,10 +262,10 @@ def hold_for_approval(mlist, msg, msgdata, exc): member = mlist.members.get_member(sender) lang = (member.preferred_language if member else mlist.preferred_language) if not fromusenet and ackp(msg) and mlist.respond_to_post_requests and \ - mlist.autorespondToSender(sender, lang): + autorespond_to_sender(mlist, sender, lang): # Get a confirmation token - d['confirmurl'] = '%s/%s' % (mlist.GetScriptURL('confirm', absolute=1), - token) + d['confirmurl'] = '%s/%s' % ( + mlist.script_url('confirm'), token) lang = msgdata.get('lang', lang) subject = _('Your message to $listname awaits moderator approval') text = Utils.maketext('postheld.txt', d, lang=lang, mlist=mlist) diff --git a/Mailman/Handlers/Replybot.py b/Mailman/Handlers/Replybot.py index 18fc83ced..7017d9dd5 100644 --- a/Mailman/Handlers/Replybot.py +++ b/Mailman/Handlers/Replybot.py @@ -19,6 +19,7 @@ import time import logging +import datetime from string import Template @@ -29,6 +30,7 @@ from Mailman.i18n import _ log = logging.getLogger('mailman.error') __i18n_templates__ = True +NODELTA = datetime.timedelta() @@ -63,7 +65,7 @@ def process(mlist, msg, msgdata): sender = msg.get_sender() now = time.time() graceperiod = mlist.autoresponse_graceperiod - if graceperiod > 0 and ack <> 'yes': + if graceperiod > NODELTA and ack <> 'yes': if toadmin: quiet_until = mlist.admin_responses.get(sender, 0) elif torequest: @@ -79,7 +81,7 @@ def process(mlist, msg, msgdata): 'Auto-response for your message to the "$realname" mailing list') # Do string interpolation into the autoresponse text d = dict(listname = realname, - listurl = mlist.GetScriptURL('listinfo'), + listurl = mlist.script_url('listinfo'), requestemail = mlist.request_address, owneremail = mlist.owner_address, ) @@ -98,7 +100,7 @@ def process(mlist, msg, msgdata): outmsg['X-Ack'] = 'No' outmsg.send(mlist) # update the grace period database - if graceperiod > 0: + if graceperiod > NODELTA: # graceperiod is in days, we need # of seconds quiet_until = now + graceperiod * 24 * 60 * 60 if toadmin: diff --git a/Mailman/Handlers/Scrubber.py b/Mailman/Handlers/Scrubber.py index a70cb0c71..655742899 100644 --- a/Mailman/Handlers/Scrubber.py +++ b/Mailman/Handlers/Scrubber.py @@ -38,6 +38,7 @@ from Mailman import LockFile from Mailman import Message from Mailman import Utils from Mailman.Errors import DiscardMessage +from Mailman.app.archiving import get_base_archive_url from Mailman.configuration import config from Mailman.i18n import _ @@ -388,7 +389,8 @@ def makedirs(dir): def save_attachment(mlist, msg, dir, filter_html=True): - fsdir = os.path.join(mlist.archive_dir(), dir) + fsdir = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR, + mlist.fqdn_listname, dir) makedirs(fsdir) # Figure out the attachment type and get the decoded data decodedpayload = msg.get_payload(decode=True) @@ -496,7 +498,7 @@ def save_attachment(mlist, msg, dir, filter_html=True): fp.write(decodedpayload) fp.close() # Now calculate the url - baseurl = mlist.GetBaseArchiveURL() + baseurl = get_base_archive_url(mlist) # Private archives will likely have a trailing slash. Normalize. if baseurl[-1] <> '/': baseurl += '/' diff --git a/Mailman/Handlers/ToDigest.py b/Mailman/Handlers/ToDigest.py index 5fd08852f..7e2dcc6d2 100644 --- a/Mailman/Handlers/ToDigest.py +++ b/Mailman/Handlers/ToDigest.py @@ -179,7 +179,7 @@ def send_i18n_digests(mlist, mboxfp): 'masthead.txt', {'real_name' : mlist.real_name, 'got_list_email': mlist.posting_address, - 'got_listinfo_url': mlist.GetScriptURL('listinfo', absolute=1), + 'got_listinfo_url': mlist.script_url('listinfo'), 'got_request_email': mlist.request_address, 'got_owner_email': mlist.owner_address, }, mlist=mlist) diff --git a/Mailman/MailList.py b/Mailman/MailList.py index 84b098ae6..900daaa11 100644 --- a/Mailman/MailList.py +++ b/Mailman/MailList.py @@ -58,9 +58,7 @@ from Mailman.interfaces import * # Base classes from Mailman.Archiver import Archiver from Mailman.Bouncer import Bouncer -from Mailman.Deliverer import Deliverer from Mailman.Digester import Digester -from Mailman.HTMLFormatter import HTMLFormatter from Mailman.SecurityManager import SecurityManager # GUI components package @@ -85,8 +83,7 @@ slog = logging.getLogger('mailman.subscribe') # Use mixins here just to avoid having any one chunk be too large. -class MailList(object, HTMLFormatter, Deliverer, - Archiver, Digester, SecurityManager, Bouncer): +class MailList(object, Archiver, Digester, SecurityManager, Bouncer): implements( IMailingList, @@ -165,50 +162,6 @@ class MailList(object, HTMLFormatter, Deliverer, - # IMailingListAddresses - - @property - def posting_address(self): - return self.fqdn_listname - - @property - def noreply_address(self): - return '%s@%s' % (config.NO_REPLY_ADDRESS, self.host_name) - - @property - def owner_address(self): - return '%s-owner@%s' % (self.list_name, self.host_name) - - @property - def request_address(self): - return '%s-request@%s' % (self.list_name, self.host_name) - - @property - def bounces_address(self): - return '%s-bounces@%s' % (self.list_name, self.host_name) - - @property - def join_address(self): - return '%s-join@%s' % (self.list_name, self.host_name) - - @property - def leave_address(self): - return '%s-leave@%s' % (self.list_name, self.host_name) - - @property - def subscribe_address(self): - return '%s-subscribe@%s' % (self.list_name, self.host_name) - - @property - def unsubscribe_address(self): - return '%s-unsubscribe@%s' % (self.list_name, self.host_name) - - def confirm_address(self, cookie): - local_part = Template(config.VERP_CONFIRM_FORMAT).safe_substitute( - address = '%s-confirm' % self.list_name, - cookie = cookie) - return '%s@%s' % (local_part, self.host_name) - def GetConfirmJoinSubject(self, listname, cookie): if config.VERP_CONFIRMATIONS and cookie: cset = i18n.get_translation().charset() or \ @@ -393,9 +346,9 @@ class MailList(object, HTMLFormatter, Deliverer, invitee = userdesc.address Utils.ValidateEmail(invitee) # check for banned address - pattern = self.GetBannedPattern(invitee) + pattern = Utils.get_pattern(invitee, self.ban_list) if pattern: - raise Errors.MembershipIsBanned, pattern + raise Errors.MembershipIsBanned(pattern) # Hack alert! Squirrel away a flag that only invitations have, so # that we can do something slightly different when an invitation # subscription is confirmed. In those cases, we don't need further @@ -471,7 +424,7 @@ class MailList(object, HTMLFormatter, Deliverer, raise Errors.InvalidEmailAddress realname = self.real_name # Is the subscribing address banned from this list? - pattern = self.GetBannedPattern(email) + pattern = Utils.get_pattern(email, self.ban_list) if pattern: vlog.error('%s banned subscription: %s (matched: %s)', realname, email, pattern) @@ -588,7 +541,7 @@ class MailList(object, HTMLFormatter, Deliverer, # Don't allow changing to a banned address. MAS: maybe we should # unsubscribe the oldaddr too just for trying, but that's probably # too harsh. - pattern = self.GetBannedPattern(newaddr) + pattern = Utils.get_pattern(newaddr, self.ban_list) if pattern: vlog.error('%s banned address change: %s -> %s (matched: %s)', realname, oldaddr, newaddr, pattern) @@ -630,7 +583,7 @@ class MailList(object, HTMLFormatter, Deliverer, # confirmation was mailed. MAS: If it's global change should we just # skip this list and proceed to the others? For now we'll throw the # exception. - pattern = self.GetBannedPattern(newaddr) + pattern = Utils.get_pattern(newaddr, self.ban_list) if pattern: raise Errors.MembershipIsBanned, pattern # It's possible they were a member of this list, but choose to change @@ -655,7 +608,7 @@ class MailList(object, HTMLFormatter, Deliverer, if not mlist.isMember(oldaddr): continue # If new address is banned from this list, just skip it. - if mlist.GetBannedPattern(newaddr): + if Utils.get_pattern(newaddr, mlist.ban_list): continue mlist.Lock() try: @@ -859,194 +812,18 @@ class MailList(object, HTMLFormatter, Deliverer, # # Miscellaneous stuff # - def HasExplicitDest(self, msg): - """True if list name or any acceptable_alias is included among the - addresses in the recipient headers. - """ - # This is the list's full address. - recips = [] - # Check all recipient addresses against the list's explicit addresses, - # specifically To: Cc: and Resent-to: - to = [] - for header in ('to', 'cc', 'resent-to', 'resent-cc'): - to.extend(getaddresses(msg.get_all(header, []))) - for fullname, addr in to: - # It's possible that if the header doesn't have a valid RFC 2822 - # value, we'll get None for the address. So skip it. - if addr is None: - continue - addr = addr.lower() - localpart = addr.split('@')[0] - if (# TBD: backwards compatibility: deprecated - localpart == self.list_name or - # exact match against the complete list address - addr == self.fqdn_listname): - return True - recips.append((addr, localpart)) - # Helper function used to match a pattern against an address. - def domatch(pattern, addr): - try: - if re.match(pattern, addr, re.IGNORECASE): - return True - except re.error: - # The pattern is a malformed regexp -- try matching safely, - # with all non-alphanumerics backslashed: - if re.match(re.escape(pattern), addr, re.IGNORECASE): - return True - return False - # Here's the current algorithm for matching acceptable_aliases: - # - # 1. If the pattern does not have an `@' in it, we first try matching - # it against just the localpart. This was the behavior prior to - # 2.0beta3, and is kept for backwards compatibility. (deprecated). - # - # 2. If that match fails, or the pattern does have an `@' in it, we - # try matching against the entire recip address. - aliases = self.acceptable_aliases.splitlines() - for addr, localpart in recips: - for alias in aliases: - stripped = alias.strip() - if not stripped: - # Ignore blank or empty lines - continue - if '@' not in stripped and domatch(stripped, localpart): - return True - if domatch(stripped, addr): - return True - return False - - def parse_matching_header_opt(self): - """Return a list of triples [(field name, regex, line), ...].""" - # - Blank lines and lines with '#' as first char are skipped. - # - Leading whitespace in the matchexp is trimmed - you can defeat - # that by, eg, containing it in gratuitous square brackets. - all = [] - for line in self.bounce_matching_headers.split('\n'): - line = line.strip() - # Skip blank lines and lines *starting* with a '#'. - if not line or line[0] == "#": - continue - i = line.find(':') - if i < 0: - # This didn't look like a header line. BAW: should do a - # better job of informing the list admin. - clog.error('bad bounce_matching_header line: %s\n%s', - self.real_name, line) - else: - header = line[:i] - value = line[i+1:].lstrip() - try: - cre = re.compile(value, re.IGNORECASE) - except re.error, e: - # The regexp was malformed. BAW: should do a better - # job of informing the list admin. - clog.error("""\ -bad regexp in bounce_matching_header line: %s -\n%s (cause: %s)""", self.real_name, value, e) - else: - all.append((header, cre, line)) - return all - - def hasMatchingHeader(self, msg): - """Return true if named header field matches a regexp in the - bounce_matching_header list variable. - - Returns constraint line which matches or empty string for no - matches. - """ - for header, cre, line in self.parse_matching_header_opt(): - for value in msg.get_all(header, []): - if cre.search(value): - return line - return 0 - - def autorespondToSender(self, sender, lang=None): - """Return true if Mailman should auto-respond to this sender. - - This is only consulted for messages sent to the -request address, or - for posting hold notifications, and serves only as a safety value for - mail loops with email 'bots. - """ - # language setting - if lang == None: - lang = self.preferred_language - i18n.set_language(lang) - # No limit - if config.MAX_AUTORESPONSES_PER_DAY == 0: - return 1 - today = time.localtime()[:3] - info = self.hold_and_cmd_autoresponses.get(sender) - if info is None or info[0] <> today: - # First time we've seen a -request/post-hold for this sender - self.hold_and_cmd_autoresponses[sender] = (today, 1) - # BAW: no check for MAX_AUTORESPONSES_PER_DAY <= 1 - return 1 - date, count = info - if count < 0: - # They've already hit the limit for today. - vlog.info('-request/hold autoresponse discarded for: %s', sender) - return 0 - if count >= config.MAX_AUTORESPONSES_PER_DAY: - vlog.info('-request/hold autoresponse limit hit for: %s', sender) - self.hold_and_cmd_autoresponses[sender] = (today, -1) - # Send this notification message instead - text = Utils.maketext( - 'nomoretoday.txt', - {'sender' : sender, - 'listname': self.fqdn_listname, - 'num' : count, - 'owneremail': self.GetOwnerEmail(), - }, - lang=lang) - msg = Message.UserNotification( - sender, self.GetOwnerEmail(), - _('Last autoresponse notification for today'), - text, lang=lang) - msg.send(self) - return 0 - self.hold_and_cmd_autoresponses[sender] = (today, count+1) - return 1 - - def GetBannedPattern(self, email): - """Returns matched entry in ban_list if email matches. - Otherwise returns None. - """ - return self.ban_list and self.GetPattern(email, self.ban_list) def HasAutoApprovedSender(self, sender): """Returns True and logs if sender matches address or pattern in subscribe_auto_approval. Otherwise returns False. """ auto_approve = False - if self.GetPattern(sender, self.subscribe_auto_approval): + if Utils.get_pattern(sender, self.subscribe_auto_approval): auto_approve = True vlog.info('%s: auto approved subscribe from %s', self.internal_name(), sender) return auto_approve - def GetPattern(self, email, pattern_list): - """Returns matched entry in pattern_list if email matches. - Otherwise returns None. - """ - matched = None - for pattern in pattern_list: - if pattern.startswith('^'): - # This is a regular expression match - try: - if re.search(pattern, email, re.IGNORECASE): - matched = pattern - break - except re.error: - # BAW: we should probably remove this pattern - pass - else: - # Do the comparison case insensitively - if pattern.lower() == email.lower(): - matched = pattern - break - return matched - - # # Multilingual (i18n) support diff --git a/Mailman/Queue/CommandRunner.py b/Mailman/Queue/CommandRunner.py index 30f1e9bdf..2df28e312 100644 --- a/Mailman/Queue/CommandRunner.py +++ b/Mailman/Queue/CommandRunner.py @@ -36,6 +36,7 @@ from Mailman import Message from Mailman import Utils from Mailman.Handlers import Replybot from Mailman.Queue.Runner import Runner +from Mailman.app.replybot import autorespond_to_sender from Mailman.configuration import config from Mailman.i18n import _ @@ -176,7 +177,7 @@ To obtain instructions, send a message containing just the word "help". # BAW: We wait until now to make this decision since our sender may # not be self.msg.get_sender(), but I'm not sure this is right. recip = self.returnaddr or self.msg.get_sender() - if not self.mlist.autorespondToSender(recip, self.msgdata['lang']): + if not autorespond_to_sender(self.mlist, recip, self.msgdata['lang']): return msg = Message.UserNotification( recip, diff --git a/Mailman/Utils.py b/Mailman/Utils.py index 9d6fdf145..0bd8fa2a6 100644 --- a/Mailman/Utils.py +++ b/Mailman/Utils.py @@ -887,3 +887,31 @@ def strip_verbose_pattern(pattern): newpattern += c i += 1 return newpattern + + + +def get_pattern(email, pattern_list): + """Returns matched entry in pattern_list if email matches. + Otherwise returns None. + """ + if not pattern_list: + return None + matched = None + for pattern in pattern_list: + if pattern.startswith('^'): + # This is a regular expression match + try: + if re.search(pattern, email, re.IGNORECASE): + matched = pattern + break + except re.error: + # BAW: we should probably remove this pattern + pass + else: + # Do the comparison case insensitively + if pattern.lower() == email.lower(): + matched = pattern + break + return matched + + diff --git a/Mailman/app/archiving.py b/Mailman/app/archiving.py new file mode 100644 index 000000000..6ab3479eb --- /dev/null +++ b/Mailman/app/archiving.py @@ -0,0 +1,36 @@ +# Copyright (C) 2007 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. + +"""Application level archiving support.""" + +from string import Template + +from Mailman.configuration import config + + + +def get_base_archive_url(mlist): + if mlist.archive_private: + url = mlist.script_url('private') + '/index.html' + else: + web_host = config.domains.get(mlist.host_name, mlist.host_name) + url = Template(config.PUBLIC_ARCHIVE_URL).safe_substitute( + listname=mlist.fqdn_listname, + hostname=web_host, + fqdn_listname=mlist.fqdn_listname, + ) + return url diff --git a/Mailman/app/bounces.py b/Mailman/app/bounces.py new file mode 100644 index 000000000..6df5c8aa6 --- /dev/null +++ b/Mailman/app/bounces.py @@ -0,0 +1,163 @@ +# Copyright (C) 2007 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. + +"""Application level bounce handling.""" + +__all__ = [ + 'bounce_message', + 'has_explicit_destination', + 'has_matching_bounce_header', + ] + +import re +import logging + +from email.mime.message import MIMEMessage +from email.mime.text import MIMEText +from email.utils import getaddresses + +from Mailman import Message +from Mailman import Utils +from Mailman.i18n import _ + +log = logging.getLogger('mailman.config') + + + +def bounce_message(mlist, msg, 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(mlist.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(), + mlist.owner_address, + subject, + lang=mlist.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(mlist.preferred_language)) + bmsg.attach(txt) + bmsg.attach(MIMEMessage(msg)) + bmsg.send(mlist) + + + +# Helper function used to match a pattern against an address. +def _domatch(pattern, addr): + try: + if re.match(pattern, addr, re.IGNORECASE): + return True + except re.error: + # The pattern is a malformed regexp -- try matching safely, + # with all non-alphanumerics backslashed: + if re.match(re.escape(pattern), addr, re.IGNORECASE): + return True + return False + + +def has_explicit_destination(mlist, msg): + """Does the list's name or an acceptable alias appear in the recipients? + + :param mlist: The mailing list the message is destined for. + :param msg: The email message object. + :return: True if the message is explicitly destined for the mailing list, + otherwise False. + """ + # Check all recipient addresses against the list's explicit addresses, + # specifically To: Cc: and Resent-to: + recipients = [] + to = [] + for header in ('to', 'cc', 'resent-to', 'resent-cc'): + to.extend(getaddresses(msg.get_all(header, []))) + for fullname, address in to: + # It's possible that if the header doesn't have a valid RFC 2822 + # value, we'll get None for the address. So skip it. + if address is None or '@' not in address: + continue + address = address.lower() + if address == mlist.posting_address: + return True + recipients.append(address) + # Match the set of recipients against the list's acceptable aliases. + aliases = mlist.acceptable_aliases.splitlines() + for address in recipients: + for alias in aliases: + stripped = alias.strip() + if not stripped: + # Ignore blank or empty lines + continue + if domatch(stripped, address): + return True + return False + + + +def _parse_matching_header_opt(mlist): + """Return a list of triples [(field name, regex, line), ...].""" + # - Blank lines and lines with '#' as first char are skipped. + # - Leading whitespace in the matchexp is trimmed - you can defeat + # that by, eg, containing it in gratuitous square brackets. + all = [] + for line in mlist.bounce_matching_headers.splitlines(): + line = line.strip() + # Skip blank lines and lines *starting* with a '#'. + if not line or line.startswith('#'): + continue + i = line.find(':') + if i < 0: + # This didn't look like a header line. BAW: should do a + # better job of informing the list admin. + log.error('bad bounce_matching_header line: %s\n%s', + mlist.real_name, line) + else: + header = line[:i] + value = line[i+1:].lstrip() + try: + cre = re.compile(value, re.IGNORECASE) + except re.error, e: + # The regexp was malformed. BAW: should do a better + # job of informing the list admin. + log.error("""\ +bad regexp in bounce_matching_header line: %s +\n%s (cause: %s)""", mlist.real_name, value, e) + else: + all.append((header, cre, line)) + return all + + +def has_matching_bounce_header(mlist, msg): + """Does the message have a matching bounce header? + + :param mlist: The mailing list the message is destined for. + :param msg: The email message object. + :return: True if a header field matches a regexp in the + bounce_matching_header mailing list variable. + """ + for header, cre, line in _parse_matching_header_opt(mlist): + for value in msg.get_all(header, []): + if cre.search(value): + return True + return False diff --git a/Mailman/app/lifecycle.py b/Mailman/app/lifecycle.py index 1c40feaeb..8c693d39d 100644 --- a/Mailman/app/lifecycle.py +++ b/Mailman/app/lifecycle.py @@ -53,7 +53,7 @@ def create_list(fqdn_listname, owners=None): # be necessary. Until then, setattr on the MailList instance won't # set the database column values, so pass the underlying database # object to .apply() instead. - style.apply(mlist._data) + style.apply(mlist) # Coordinate with the MTA, which should be defined by plugins. # XXX FIXME ## mta_plugin = get_plugin('mailman.mta') diff --git a/Mailman/app/membership.py b/Mailman/app/membership.py index 0f12a1d29..3096ae7f4 100644 --- a/Mailman/app/membership.py +++ b/Mailman/app/membership.py @@ -58,7 +58,7 @@ def add_member(mlist, address, realname, password, delivery_mode, language, raise Errors.AlreadySubscribedError(address) # Check for banned address here too for admin mass subscribes and # confirmations. - pattern = mlist.GetBannedPattern(address) + pattern = Utils.get_pattern(address, mlist.ban_list) if pattern: raise Errors.MembershipIsBanned(pattern) # Do the actual addition. First, see if there's already a user linked diff --git a/Mailman/app/replybot.py b/Mailman/app/replybot.py new file mode 100644 index 000000000..c46931770 --- /dev/null +++ b/Mailman/app/replybot.py @@ -0,0 +1,89 @@ +# Copyright (C) 2007 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. + +"""Application level auto-reply code.""" + +# XXX This should undergo a rewrite to move this functionality off of the +# mailing list. The reply governor should really apply site-wide per +# recipient (I think). + +from __future__ import with_statement + +__all__ = [ + 'autorespond_to_sender', + ] + +import logging +import datetime + +from Mailman import Utils +from Mailman import i18n +from Mailman.configuration import config + + +log = logging.getLogger('mailman.vette') +_ = i18n._ + + + +def autorespond_to_sender(mlist, sender, lang=None): + """Return True if Mailman should auto-respond to this sender. + + This is only consulted for messages sent to the -request address, or + for posting hold notifications, and serves only as a safety value for + mail loops with email 'bots. + """ + if lang is None: + lang = mlist.preferred_language + if config.MAX_AUTORESPONSES_PER_DAY == 0: + # Unlimited. + return True + today = datetime.date.today() + info = mlist.hold_and_cmd_autoresponses.get(sender) + if info is None or info[0] <> today: + # This is the first time we've seen a -request/post-hold for this + # sender today. + mlist.hold_and_cmd_autoresponses[sender] = (today, 1) + return True + date, count = info + if count < 0: + # They've already hit the limit for today, and we've already notified + # them of this fact, so there's nothing more to do. + log.info('-request/hold autoresponse discarded for: %s', sender) + return False + if count >= config.MAX_AUTORESPONSES_PER_DAY: + log.info('-request/hold autoresponse limit hit for: %s', sender) + mlist.hold_and_cmd_autoresponses[sender] = (today, -1) + # Send this notification message instead. + text = Utils.maketext( + 'nomoretoday.txt', + {'sender' : sender, + 'listname': mlist.fqdn_listname, + 'num' : count, + 'owneremail': mlist.owner_address, + }, + lang=lang) + with i18n.using_language(lang): + msg = Message.UserNotification( + sender, mlist.owner_address, + _('Last autoresponse notification for today'), + text, lang=lang) + msg.send(mlist) + return False + mlist.hold_and_cmd_autoresponses[sender] = (today, count + 1) + return True + diff --git a/Mailman/database/listmanager.py b/Mailman/database/listmanager.py index d5a6303e6..0f6d7a9aa 100644 --- a/Mailman/database/listmanager.py +++ b/Mailman/database/listmanager.py @@ -17,7 +17,6 @@ """SQLAlchemy/Elixir based provider of IListManager.""" -import weakref import datetime from elixir import * @@ -34,9 +33,6 @@ from Mailman.interfaces import IListManager, IPending class ListManager(object): implements(IListManager) - def __init__(self): - self._objectmap = weakref.WeakKeyDictionary() - def create(self, fqdn_listname): listname, hostname = split_listname(fqdn_listname) mlist = MailingList.get_by(list_name=listname, @@ -45,30 +41,18 @@ class ListManager(object): raise Errors.MMListAlreadyExistsError(fqdn_listname) mlist = MailingList(fqdn_listname) mlist.created_at = datetime.datetime.now() - # Wrap the database model object in an application MailList object and - # return the latter. Keep track of the wrapper so we can clean it up - # when we're done with it. - from Mailman.MailList import MailList - wrapper = MailList(mlist) - self._objectmap[mlist] = wrapper - return wrapper + return mlist def delete(self, mlist): - # Delete the wrapped backing data. XXX It's kind of icky to reach - # into the MailList object this way. - mlist._data.delete() - mlist._data = None + mlist.delete() def get(self, fqdn_listname): listname, hostname = split_listname(fqdn_listname) - mlist = MailingList.get_by(list_name=listname, - host_name=hostname) - if not mlist: - return None - mlist._restore() - from Mailman.MailList import MailList - wrapper = self._objectmap.setdefault(mlist, MailList(mlist)) - return wrapper + mlist = MailingList.get_by(list_name=listname, host_name=hostname) + if mlist is not None: + # XXX Fixme + mlist._restore() + return mlist @property def mailing_lists(self): diff --git a/Mailman/database/model/mailinglist.py b/Mailman/database/model/mailinglist.py index 0d12f919e..7fa9aca38 100644 --- a/Mailman/database/model/mailinglist.py +++ b/Mailman/database/model/mailinglist.py @@ -211,3 +211,51 @@ class MailingList(Entity): # XXX Handle the case for when context is not None; those would be # relative URLs. return self.web_page_url + target + '/' + self.fqdn_listname + + # IMailingListAddresses + + @property + def posting_address(self): + return self.fqdn_listname + + @property + def noreply_address(self): + return '%s@%s' % (config.NO_REPLY_ADDRESS, self.host_name) + + @property + def owner_address(self): + return '%s-owner@%s' % (self.list_name, self.host_name) + + @property + def request_address(self): + return '%s-request@%s' % (self.list_name, self.host_name) + + @property + def bounces_address(self): + return '%s-bounces@%s' % (self.list_name, self.host_name) + + @property + def join_address(self): + return '%s-join@%s' % (self.list_name, self.host_name) + + @property + def leave_address(self): + return '%s-leave@%s' % (self.list_name, self.host_name) + + @property + def subscribe_address(self): + return '%s-subscribe@%s' % (self.list_name, self.host_name) + + @property + def unsubscribe_address(self): + return '%s-unsubscribe@%s' % (self.list_name, self.host_name) + + def confirm_address(self, cookie): + template = string.Template(config.VERP_CONFIRM_FORMAT) + local_part = template.safe_substitute( + address = '%s-confirm' % self.list_name, + cookie = cookie) + return '%s@%s' % (local_part, self.host_name) + + def __repr__(self): + return '<mailing list "%s" at %#x>' % (self.fqdn_listname, id(self)) diff --git a/Mailman/database/model/requests.py b/Mailman/database/model/requests.py index b0aa0d8d0..ea917c2b9 100644 --- a/Mailman/database/model/requests.py +++ b/Mailman/database/model/requests.py @@ -49,22 +49,22 @@ class ListRequests: @property def count(self): - results = _Request.select_by(mailing_list=self.mailing_list._data) + results = _Request.select_by(mailing_list=self.mailing_list) return len(results) def count_of(self, request_type): - results = _Request.select_by(mailing_list=self.mailing_list._data, + results = _Request.select_by(mailing_list=self.mailing_list, type=request_type) return len(results) @property def held_requests(self): - results = _Request.select_by(mailing_list=self.mailing_list._data) + results = _Request.select_by(mailing_list=self.mailing_list) for request in results: yield request def of_type(self, request_type): - results = _Request.select_by(mailing_list=self.mailing_list._data, + results = _Request.select_by(mailing_list=self.mailing_list, type=request_type) for request in results: yield request @@ -88,12 +88,12 @@ class ListRequests: # flush()'s. ## result = _Request.table.insert().execute( ## key=key, type=request_type, -## mailing_list=self.mailing_list._data, +## mailing_list=self.mailing_list, ## data_hash=data_hash) ## row_id = result.last_inserted_ids()[0] ## return row_id result = _Request(key=key, type=request_type, - mailing_list=self.mailing_list._data, + mailing_list=self.mailing_list, data_hash=data_hash) # XXX We need a handle on last_inserted_ids() instead of requiring a # flush of the database to get a valid id. diff --git a/Mailman/docs/acknowledge.txt b/Mailman/docs/acknowledge.txt index e9242fb45..3e2088f10 100644 --- a/Mailman/docs/acknowledge.txt +++ b/Mailman/docs/acknowledge.txt @@ -15,7 +15,7 @@ acknowledgment. >>> mlist.preferred_language = 'en' >>> # XXX This will almost certainly change once we've worked out the web >>> # space layout for mailing lists now. - >>> mlist._data.web_page_url = 'http://lists.example.com/' + >>> mlist.web_page_url = 'http://lists.example.com/' >>> flush() >>> # Ensure that the virgin queue is empty, since we'll be checking this diff --git a/Mailman/docs/bounces.txt b/Mailman/docs/bounces.txt index aa1639e5a..cfc7aa49e 100644 --- a/Mailman/docs/bounces.txt +++ b/Mailman/docs/bounces.txt @@ -37,7 +37,8 @@ to the original messageauthor. >>> from Mailman.Queue.Switchboard import Switchboard >>> switchboard = Switchboard(config.VIRGINQUEUE_DIR) - >>> mlist.bounce_message(msg) + >>> from Mailman.app.bounces import bounce_message + >>> bounce_message(mlist, msg) >>> len(switchboard.files) 1 >>> filebase = switchboard.files[0] @@ -77,7 +78,7 @@ passed in as an instance of a RejectMessage exception. >>> from Mailman.Errors import RejectMessage >>> error = RejectMessage("This wasn't very important after all.") - >>> mlist.bounce_message(msg, error) + >>> bounce_message(mlist, msg, error) >>> len(switchboard.files) 1 >>> filebase = switchboard.files[0] diff --git a/Mailman/docs/cook-headers.txt b/Mailman/docs/cook-headers.txt index 4f8885e79..ffe2dfa5f 100644 --- a/Mailman/docs/cook-headers.txt +++ b/Mailman/docs/cook-headers.txt @@ -18,7 +18,7 @@ is getting sent through the system. We'll take things one-by-one. >>> mlist.archive = True >>> # XXX This will almost certainly change once we've worked out the web >>> # space layout for mailing lists now. - >>> mlist._data.web_page_url = 'http://lists.example.com/' + >>> mlist.web_page_url = 'http://lists.example.com/' >>> flush() diff --git a/Mailman/docs/hold.txt b/Mailman/docs/hold.txt index 62b621bdc..3aeb56934 100644 --- a/Mailman/docs/hold.txt +++ b/Mailman/docs/hold.txt @@ -16,7 +16,7 @@ are held when they meet any of a number of criteria. >>> mlist.real_name = '_XTest' >>> # XXX This will almost certainly change once we've worked out the web >>> # space layout for mailing lists now. - >>> mlist._data.web_page_url = 'http://lists.example.com/' + >>> mlist.web_page_url = 'http://lists.example.com/' >>> flush() Here's a helper function used when we don't care about what's in the virgin diff --git a/Mailman/docs/listmanager.txt b/Mailman/docs/listmanager.txt index 1f4586e35..030f2ecd5 100644 --- a/Mailman/docs/listmanager.txt +++ b/Mailman/docs/listmanager.txt @@ -61,14 +61,6 @@ Use the list manager to delete a mailing list. >>> sorted(listmgr.names) [] -Attempting to access attributes of the deleted mailing list raises an -exception: - - >>> mlist.fqdn_listname - Traceback (most recent call last): - ... - AttributeError: fqdn_listname - After deleting the list, you can create it again. >>> mlist = listmgr.create('_xtest@example.com') diff --git a/Mailman/docs/replybot.txt b/Mailman/docs/replybot.txt index 2db4e4b07..0b1980fde 100644 --- a/Mailman/docs/replybot.txt +++ b/Mailman/docs/replybot.txt @@ -13,6 +13,7 @@ message or the amount of time since the last auto-response. >>> from Mailman.database import flush >>> mlist = config.db.list_manager.create('_xtest@example.com') >>> mlist.real_name = 'XTest' + >>> mlist.web_page_url = 'http://www.example.com/' >>> flush() >>> # Ensure that the virgin queue is empty, since we'll be checking this @@ -32,8 +33,9 @@ is sent to one of these addresses. A mailing list also has an autoresponse grace period which describes how much time must pass before a second response will be sent, with 0 meaning "there is no grace period". + >>> import datetime >>> mlist.autorespond_admin = True - >>> mlist.autoresponse_graceperiod = 0 + >>> mlist.autoresponse_graceperiod = datetime.timedelta() >>> mlist.autoresponse_admin_text = 'admin autoresponse text' >>> flush() >>> msg = message_from_string("""\ diff --git a/Mailman/docs/requests.txt b/Mailman/docs/requests.txt index e513b7cea..249cab952 100644 --- a/Mailman/docs/requests.txt +++ b/Mailman/docs/requests.txt @@ -451,7 +451,7 @@ queue when the message is held. >>> mlist.admin_immed_notify = True >>> # XXX This will almost certainly change once we've worked out the web >>> # space layout for mailing lists now. - >>> mlist._data.web_page_url = 'http://www.example.com/' + >>> mlist.web_page_url = 'http://www.example.com/' >>> flush() >>> id_4 = moderator.hold_subscription(mlist, ... 'cperson@example.org', 'Claire Person', diff --git a/Mailman/docs/scrubber.txt b/Mailman/docs/scrubber.txt index 344894666..0c8c4d94f 100644 --- a/Mailman/docs/scrubber.txt +++ b/Mailman/docs/scrubber.txt @@ -19,7 +19,8 @@ Helper functions for getting the attachment data. >>> import os, re >>> def read_attachment(filename, remove=True): - ... path = os.path.join(mlist.archive_dir(), filename) + ... path = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR, + ... mlist.fqdn_listname, filename) ... fp = open(path) ... try: ... data = fp.read() @@ -4,7 +4,7 @@ things that I need to do. Fix the XXX in model/requests.py where we need a flush because we can't get to last_inserted_id() Get rid of PickleTypes -Get rid of MailList class! +Get rid of MailList class! (done for test suite!) Add tests for bin/newlist and bin/rmlist Add tests for plugins Rework MTA plugins and add tests |
