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 | |
OMGW00T: After over a decade, the MailList mixin class is gone! Well,
mostly. It's no longer needed by anything in the test suite, and
therefore the list manager returns database MailingList objects
directly. The wrapper cruft has been removed.
To accomplish this, a couple of hacks were added to the Mailman.app
package, which will get cleaned up over time. The MailList module
itself (and its few remaining mixins) aren't yet removed from the tree
because some of the code is still not tested, and I want to leave this
code around until I've finished converting it.
| -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 |
