diff options
87 files changed, 3792 insertions, 1606 deletions
diff --git a/Mailman/Defaults.py b/Mailman/Defaults.py index c4d9d7dbc..e37545e52 100644 --- a/Mailman/Defaults.py +++ b/Mailman/Defaults.py @@ -121,9 +121,6 @@ DEFAULT_VAR_DIRECTORY = '/var/mailman' # any variable in the Configuration object. DEFAULT_DATABASE_URL = 'sqlite:///$DATA_DIR/mailman.db' -# For debugging purposes -SQLALCHEMY_ECHO = False - ##### @@ -164,16 +161,21 @@ DEFAULT_URL_HOST = '@URLHOST@' # Spam avoidance defaults ##### -# This variable contains a list of 2-tuple of the format (header, regex) which -# the Mailman/Handlers/SpamDetect.py module uses to match against the current -# message. If the regex matches the given header in the current message, then -# it is flagged as spam. header is case-insensitive and should not include -# the trailing colon. regex is always matched with re.IGNORECASE. +# This variable contains a list of tuple of the format: +# +# (header, pattern[, chain]) +# +# which is used to match against the current message's headers. If the +# pattern matches the given header in the current message, then the named +# chain is jumped to. header is case-insensitive and should not include the +# trailing colon. pattern is always matched with re.IGNORECASE. chain is +# optional; if not given the 'hold' chain is used, but if given it may be any +# existing chain, such as 'discard', 'reject', or 'accept'. # -# Note that the more searching done, the slower the whole process gets. Spam -# detection is run against all messages coming to either the list, or the -# -owners address, unless the message is explicitly approved. -KNOWN_SPAMMERS = [] +# Note that the more searching done, the slower the whole process gets. +# Header matching is run against all messages coming to either the list, or +# the -owners address, unless the message is explicitly approved. +HEADER_MATCHES = [] @@ -922,7 +924,7 @@ ${web_page_url}listinfo${cgiext}/${list_name} DEFAULT_SCRUB_NONDIGEST = False # Mail command processor will ignore mail command lines after designated max. -DEFAULT_MAIL_COMMANDS_MAX_LINES = 25 +EMAIL_COMMANDS_MAX_LINES = 10 # Is the list owner notified of admin requests immediately by mail, as well as # by daily pending-request reminder? diff --git a/Mailman/Handlers/Approve.py b/Mailman/Handlers/Approve.py deleted file mode 100644 index 1198e6ea7..000000000 --- a/Mailman/Handlers/Approve.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (C) 1998-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. - -"""Determine whether the message is pre-approved for delivery.""" - -import re - -from email.Iterators import typed_subpart_iterator - -from Mailman import Errors -from Mailman.configuration import config - -EMPTYSTRING = '' - - - -def process(mlist, msg, msgdata): - # Short circuits - if msgdata.get('approved'): - # Digests, Usenet postings, and some other messages come pre-approved. - # XXX we may want to further filter Usenet messages, so the test above - # may not be entirely correct. - return - # See if the message has an Approved or Approve header with a valid - # moderator password. Also look at the first non-whitespace line in the - # file to see if it looks like an Approved header. - missing = object() - password = msg.get('approved', msg.get('approve', missing)) - if password is missing: - # Find the first text/plain part in the message - part = None - stripped = False - for part in typed_subpart_iterator(msg, 'text', 'plain'): - break - # XXX I'm not entirely sure why, but it is possible for the payload of - # the part to be None, and you can't splitlines() on None. - if part and part.get_payload() is not None: - lines = part.get_payload(decode=True).splitlines(True) - for lineno, line in enumerate(lines): - if line.strip(): - break - if ':' in line: - header, value = line.split(':', 1) - if header.lower() in ('approved', 'approve'): - password = value.strip() - # Now strip the first line from the payload so the - # password doesn't leak. - del lines[lineno] - reset_payload(part, EMPTYSTRING.join(lines)) - stripped = True - if stripped: - # MAS: Bug 1181161 - Now try all the text parts in case it's - # multipart/alternative with the approved line in HTML or other - # text part. We make a pattern from the Approved line and delete - # it from all text/* parts in which we find it. It would be - # better to just iterate forward, but email compatability for pre - # Python 2.2 returns a list, not a true iterator. - # - # This will process all the multipart/alternative parts in the - # message as well as all other text parts. We shouldn't find the - # pattern outside the multipart/alternative parts, but if we do, - # it is probably best to delete it anyway as it does contain the - # password. - # - # Make a pattern to delete. We can't just delete a line because - # line of HTML or other fancy text may include additional message - # text. This pattern works with HTML. It may not work with rtf - # or whatever else is possible. - pattern = header + ':(\s| )*' + re.escape(password) - for part in typed_subpart_iterator(msg, 'text'): - if part is not None and part.get_payload() is not None: - lines = part.get_payload(decode=True) - if re.search(pattern, lines): - reset_payload(part, re.sub(pattern, '', lines)) - if password is not missing and password == mlist.moderator_password: - # BAW: should we definitely deny if the password exists but does not - # match? For now we'll let it percolate up for further determination. - msgdata['approved'] = True - # Used by the Emergency module - msgdata['adminapproved'] = True - # Has this message already been posted to this list? - beentheres = [s.strip().lower() for s in msg.get_all('x-beenthere', [])] - if mlist.posting_address in beentheres: - raise Errors.LoopError - - -def reset_payload(part, payload): - # Set decoded payload maintaining content-type, format and delsp. - # TK: Messages with 'charset=' cause trouble. So, instead of - # part.get_content_charset('us-ascii') ... - cset = part.get_content_charset() or 'us-ascii' - ctype = part.get_content_type() - format = part.get_param('format') - delsp = part.get_param('delsp') - del part['content-transfer-encoding'] - del part['content-type'] - part.set_payload(payload, cset) - part.set_type(ctype) - if format: - part.set_param('Format', format) - if delsp: - part.set_param('DelSp', delsp) diff --git a/Mailman/Handlers/Decorate.py b/Mailman/Handlers/Decorate.py index 984cd9670..6891fed8d 100644 --- a/Mailman/Handlers/Decorate.py +++ b/Mailman/Handlers/Decorate.py @@ -57,6 +57,7 @@ def process(mlist, msg, msgdata): except Errors.NotAMemberError: pass # These strings are descriptive for the log file and shouldn't be i18n'd + d.update(msgdata.get('decoration-data', {})) header = decorate(mlist, mlist.msg_header, d) footer = decorate(mlist, mlist.msg_footer, d) # Escape hatch if both the footer and header are empty diff --git a/Mailman/Handlers/Emergency.py b/Mailman/Handlers/Emergency.py deleted file mode 100644 index a282da621..000000000 --- a/Mailman/Handlers/Emergency.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (C) 2002-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. - -"""Put an emergency hold on all messages otherwise approved. - -No notices are sent to either the sender or the list owner for emergency -holds. I think they'd be too obnoxious. -""" - -from Mailman import Errors -from Mailman.i18n import _ - - - -class EmergencyHold(Errors.HoldMessage): - reason = _('Emergency hold on all list traffic is in effect') - rejection = _('Your message was deemed inappropriate by the moderator.') - - - -def process(mlist, msg, msgdata): - if mlist.emergency and not msgdata.get('adminapproved'): - mlist.HoldMessage(msg, _(EmergencyHold.reason), msgdata) - raise EmergencyHold diff --git a/Mailman/Handlers/Hold.py b/Mailman/Handlers/Hold.py deleted file mode 100644 index f53c168c5..000000000 --- a/Mailman/Handlers/Hold.py +++ /dev/null @@ -1,315 +0,0 @@ -# Copyright (C) 1998-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. - -"""Determine whether this message should be held for approval. - -This modules tests only for hold situations, such as messages that are too -large, messages that have potential administrivia, etc. Definitive approvals -or denials are handled by a different module. - -If no determination can be made (i.e. none of the hold criteria matches), then -we do nothing. If the message must be held for approval, then the hold -database is updated and any administrator notification messages are sent. -Finally an exception is raised to let the pipeline machinery know that further -message handling should stop. -""" - -from __future__ import with_statement - -import email -import logging -import email.utils - -from email.mime.message import MIMEMessage -from email.mime.text import MIMEText -from types import ClassType -from zope.interface import implements - -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 - -log = logging.getLogger('mailman.vette') - -# Play footsie with _ so that the following are marked as translated, but -# aren't actually translated until we need the text later on. -def _(s): - return s - -__i18n_templates__ = True - - - -class ForbiddenPoster(Errors.HoldMessage): - reason = _('Sender is explicitly forbidden') - rejection = _('You are forbidden from posting messages to this list.') - -class ModeratedPost(Errors.HoldMessage): - reason = _('Post to moderated list') - rejection = _('Your message was deemed inappropriate by the moderator.') - -class NonMemberPost(Errors.HoldMessage): - reason = _('Post by non-member to a members-only list') - rejection = _('Non-members are not allowed to post messages to this list.') - -class NotExplicitlyAllowed(Errors.HoldMessage): - reason = _('Posting to a restricted list by sender requires approval') - rejection = _('This list is restricted; your message was not approved.') - -class TooManyRecipients(Errors.HoldMessage): - reason = _('Too many recipients to the message') - rejection = _('Please trim the recipient list; it is too long.') - -class ImplicitDestination(Errors.HoldMessage): - reason = _('Message has implicit destination') - rejection = _('''Blind carbon copies or other implicit destinations are -not allowed. Try reposting your message by explicitly including the list -address in the To: or Cc: fields.''') - -class Administrivia(Errors.HoldMessage): - reason = _('Message may contain administrivia') - - def rejection_notice(self, mlist): - 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 -word `help' in it to the request address, $request, for further -instructions.""") - -class SuspiciousHeaders(Errors.HoldMessage): - reason = _('Message has a suspicious header') - rejection = _('Your message had a suspicious header.') - -class MessageTooBig(Errors.HoldMessage): - def __init__(self, msgsize, limit): - Errors.HoldMessage.__init__(self) - self.__msgsize = msgsize - self.__limit = limit - - def reason_notice(self): - size = self.__msgsize - limit = self.__limit - return _('''Message body is too big: $size bytes with a limit of -$limit KB''') - - def rejection_notice(self, mlist): - kb = self.__limit - return _('''Your message was too big; please trim it to less than -$kb KB in size.''') - -class ModeratedNewsgroup(ModeratedPost): - reason = _('Posting to a moderated newsgroup') - - - -# And reset the translator -_ = i18n._ - - - -def ackp(msg): - ack = msg.get('x-ack', '').lower() - precedence = msg.get('precedence', '').lower() - if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'): - return 0 - return 1 - - - -class HeldMessagePendable(dict): - implements(IPendable) - PEND_KEY = 'held message' - - - -def process(mlist, msg, msgdata): - if msgdata.get('approved'): - return - # Get the sender of the message - listname = mlist.list_name - adminaddr = listname + '-admin' - sender = msg.get_sender() - # Special case an ugly sendmail feature: If there exists an alias of the - # form "owner-foo: bar" and sendmail receives mail for address "foo", - # sendmail will change the envelope sender of the message to "bar" before - # delivering. This feature does not appear to be configurable. *Boggle*. - if not sender or sender[:len(listname)+6] == adminaddr: - sender = msg.get_sender(use_envelope=0) - # - # Possible administrivia? - if mlist.administrivia and Utils.is_administrivia(msg): - hold_for_approval(mlist, msg, msgdata, Administrivia) - # no return - # - # Are there too many recipients to the message? - if mlist.max_num_recipients > 0: - # figure out how many recipients there are - recips = email.utils.getaddresses(msg.get_all('to', []) + - msg.get_all('cc', [])) - if len(recips) >= mlist.max_num_recipients: - hold_for_approval(mlist, msg, msgdata, TooManyRecipients) - # no return - # - # 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 has_explicit_destination(mlist, msg) and \ - not msgdata.get('fromusenet'): - # then - hold_for_approval(mlist, msg, msgdata, ImplicitDestination) - # no return - # - # Suspicious headers? - if mlist.bounce_matching_headers: - 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 - hold_for_approval(mlist, msg, msgdata, SuspiciousHeaders) - # no return - # - # Is the message too big? - if mlist.max_message_size > 0: - bodylen = 0 - for line in email.Iterators.body_line_iterator(msg): - bodylen += len(line) - for part in msg.walk(): - if part.preamble: - bodylen += len(part.preamble) - if part.epilogue: - bodylen += len(part.epilogue) - if bodylen/1024.0 > mlist.max_message_size: - hold_for_approval(mlist, msg, msgdata, - MessageTooBig(bodylen, mlist.max_message_size)) - # no return - # - # Are we gatewaying to a moderated newsgroup and is this list the - # moderator's address for the group? - if mlist.news_moderation == 2: - hold_for_approval(mlist, msg, msgdata, ModeratedNewsgroup) - - - -def hold_for_approval(mlist, msg, msgdata, exc): - # BAW: This should really be tied into the email confirmation system so - # that the message can be approved or denied via email as well as the - # web. - if isinstance(exc, ClassType) or isinstance(exc, type): - # Go ahead and instantiate it now. - exc = exc() - listname = mlist.list_name - sender = msgdata.get('sender', msg.get_sender()) - usersubject = msg.get('subject') - charset = Utils.GetCharSet(mlist.preferred_language) - if usersubject: - usersubject = Utils.oneline(usersubject, charset) - else: - usersubject = _('(no subject)') - message_id = msg.get('message-id', 'n/a') - owneraddr = mlist.owner_address - adminaddr = mlist.bounces_address - requestaddr = mlist.request_address - # We need to send both the reason and the rejection notice through the - # translator again, because of the games we play above - reason = Utils.wrap(exc.reason_notice()) - msgdata['rejection_notice'] = Utils.wrap(exc.rejection_notice(mlist)) - id = hold_message(mlist, msg, msgdata, reason) - # Now we need to craft and send a message to the list admin so they can - # deal with the held message. - d = {'listname' : listname, - 'hostname' : mlist.host_name, - 'reason' : _(reason), - 'sender' : sender, - 'subject' : usersubject, - 'admindb_url': mlist.script_url('admindb'), - } - # We may want to send a notification to the original sender too - fromusenet = msgdata.get('fromusenet') - # Since we're sending two messages, which may potentially be in different - # languages (the user's preferred and the list's preferred for the admin), - # we need to play some i18n games here. Since the current language - # context ought to be set up for the user, let's craft his message first. - # - # This message should appear to come from <list>-admin so as to handle any - # bounce processing that might be needed. - pendable = HeldMessagePendable(type=HeldMessagePendable.PEND_KEY, id=id) - token = config.db.pendings.add(pendable) - # Get the language to send the response in. If the sender is a member, - # then send it in the member's language, otherwise send it in the mailing - # list's preferred language. - 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 \ - autorespond_to_sender(mlist, sender, lang): - # Get a confirmation 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) - nmsg = Message.UserNotification(sender, adminaddr, subject, text, lang) - nmsg.send(mlist) - # Now the message for the list owners. Be sure to include the list - # moderators in this message. This one should appear to come from - # <list>-owner since we really don't need to do bounce processing on it. - if mlist.admin_immed_notify: - # Now let's temporarily set the language context to that which the - # admin is expecting. - with i18n.using_language(mlist.preferred_language): - lang = mlist.preferred_language - charset = Utils.GetCharSet(lang) - # We need to regenerate or re-translate a few values in d - d['reason'] = _(reason) - d['subject'] = usersubject - # craft the admin notification message and deliver it - subject = _('$listname post from $sender requires approval') - nmsg = Message.UserNotification(owneraddr, owneraddr, subject, - lang=lang) - nmsg.set_type('multipart/mixed') - text = MIMEText( - Utils.maketext('postauth.txt', d, raw=1, mlist=mlist), - _charset=charset) - dmsg = MIMEText(Utils.wrap(_("""\ -If you reply to this message, keeping the Subject: header intact, Mailman will -discard the held message. Do this if the message is spam. If you reply to -this message and include an Approved: header with the list password in it, the -message will be approved for posting to the list. The Approved: header can -also appear in the first line of the body of the reply.""")), - _charset=Utils.GetCharSet(lang)) - dmsg['Subject'] = 'confirm ' + token - dmsg['Sender'] = requestaddr - dmsg['From'] = requestaddr - dmsg['Date'] = email.utils.formatdate(localtime=True) - dmsg['Message-ID'] = email.utils.make_msgid() - nmsg.attach(text) - nmsg.attach(MIMEMessage(msg)) - nmsg.attach(MIMEMessage(dmsg)) - nmsg.send(mlist, **{'tomoderators': 1}) - # Log the held message - log.info('%s post from %s held, message-id=%s: %s', - listname, sender, message_id, reason) - # raise the specific MessageHeld exception to exit out of the message - # delivery pipeline - raise exc diff --git a/Mailman/Handlers/SpamDetect.py b/Mailman/Handlers/SpamDetect.py deleted file mode 100644 index b7e687284..000000000 --- a/Mailman/Handlers/SpamDetect.py +++ /dev/null @@ -1,138 +0,0 @@ -# Copyright (C) 1998-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. - -"""Do more detailed spam detection. - -This module hard codes site wide spam detection. By hacking the -KNOWN_SPAMMERS variable, you can set up more regular expression matches -against message headers. If spam is detected the message is discarded -immediately. - -TBD: This needs to be made more configurable and robust. -""" - -import re - -from cStringIO import StringIO -from email.Generator import Generator - -from Mailman import Errors -from Mailman import i18n -from Mailman.Handlers.Hold import hold_for_approval -from Mailman.configuration import config - -# First, play footsie with _ so that the following are marked as translated, -# but aren't actually translated until we need the text later on. -def _(s): - return s - - - -class SpamDetected(Errors.DiscardMessage): - """The message contains known spam""" - -class HeaderMatchHold(Errors.HoldMessage): - reason = _('The message headers matched a filter rule') - - -# And reset the translator -_ = i18n._ - - - -class Tee: - def __init__(self, outfp_a, outfp_b): - self._outfp_a = outfp_a - self._outfp_b = outfp_b - - def write(self, s): - self._outfp_a.write(s) - self._outfp_b.write(s) - - -# Class to capture the headers separate from the message body -class HeaderGenerator(Generator): - def __init__(self, outfp, mangle_from_=True, maxheaderlen=78): - Generator.__init__(self, outfp, mangle_from_, maxheaderlen) - self._headertxt = '' - - def _write_headers(self, msg): - sfp = StringIO() - oldfp = self._fp - self._fp = Tee(oldfp, sfp) - try: - Generator._write_headers(self, msg) - finally: - self._fp = oldfp - self._headertxt = sfp.getvalue() - - def header_text(self): - return self._headertxt - - - -def process(mlist, msg, msgdata): - if msgdata.get('approved'): - return - # First do site hard coded header spam checks - for header, regex in config.KNOWN_SPAMMERS: - cre = re.compile(regex, re.IGNORECASE) - for value in msg.get_all(header, []): - mo = cre.search(value) - if mo: - # we've detected spam, so throw the message away - raise SpamDetected - # Now do header_filter_rules - # TK: Collect headers in sub-parts because attachment filename - # extension may be a clue to possible virus/spam. - headers = '' - for p in msg.walk(): - g = HeaderGenerator(StringIO()) - g.flatten(p) - headers += g.header_text() - # Now reshape headers (remove extra CR and connect multiline). - headers = re.sub('\n+', '\n', headers) - headers = re.sub('\n\s', ' ', headers) - for patterns, action, empty in mlist.header_filter_rules: - if action == config.DEFER: - continue - for pattern in patterns.splitlines(): - if pattern.startswith('#'): - continue - # ignore 'empty' patterns - if not pattern.strip(): - continue - if re.search(pattern, headers, re.IGNORECASE|re.MULTILINE): - if action == config.DISCARD: - raise Errors.DiscardMessage - if action == config.REJECT: - if msgdata.get('toowner'): - # Don't send rejection notice if addressed to '-owner' - # because it may trigger a loop of notices if the - # sender address is forged. We just discard it here. - raise Errors.DiscardMessage - raise Errors.RejectMessage( - _('Message rejected by filter rule match')) - if action == config.HOLD: - if msgdata.get('toowner'): - # Don't hold '-owner' addressed message. We just - # pass it here but list-owner can set this to be - # discarded on the GUI if he wants. - return - hold_for_approval(mlist, msg, msgdata, HeaderMatchHold) - if action == config.ACCEPT: - return diff --git a/Mailman/Message.py b/Mailman/Message.py index 89bc42798..a9256dc90 100644 --- a/Mailman/Message.py +++ b/Mailman/Message.py @@ -58,7 +58,10 @@ class Message(email.message.Message): return value def get_all(self, name, failobj=None): - all_values = email.message.Message.get_all(self, name, failobj) + missing = object() + all_values = email.message.Message.get_all(self, name, missing) + if all_values is missing: + return failobj return [(unicode(value, 'ascii') if isinstance(value, str) else value) for value in all_values] diff --git a/Mailman/Post.py b/Mailman/Post.py deleted file mode 100644 index 50b9628a0..000000000 --- a/Mailman/Post.py +++ /dev/null @@ -1,62 +0,0 @@ -#! /usr/bin/env python -# -# Copyright (C) 2001-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. - -import sys - -from Mailman.configuration import config -from Mailman.queue import Switchboard - - - -def inject(listname, msg, recips=None, qdir=None): - if qdir is None: - qdir = config.INQUEUE_DIR - queue = Switchboard(qdir) - kws = {'listname' : listname, - 'tolist' : 1, - '_plaintext': 1, - } - if recips: - kws['recips'] = recips - queue.enqueue(msg, **kws) - - - -if __name__ == '__main__': - # When called as a command line script, standard input is read to get the - # list that this message is destined to, the list of explicit recipients, - # and the message to send (in its entirety). stdin must have the - # following format: - # - # line 1: the internal name of the mailing list - # line 2: the number of explicit recipients to follow. 0 means to use the - # list's membership to calculate recipients. - # line 3 - 3+recipnum: explicit recipients, one per line - # line 4+recipnum - end of file: the message in RFC 822 format (may - # include an initial Unix-from header) - listname = sys.stdin.readline().strip() - numrecips = int(sys.stdin.readline()) - if numrecips == 0: - recips = None - else: - recips = [] - for i in range(numrecips): - recips.append(sys.stdin.readline().strip()) - # If the message isn't parsable, we won't get an error here - inject(listname, sys.stdin.read(), recips) diff --git a/Mailman/Utils.py b/Mailman/Utils.py index 0bd8fa2a6..1be02e87a 100644 --- a/Mailman/Utils.py +++ b/Mailman/Utils.py @@ -537,63 +537,6 @@ def maketext(templatefile, dict=None, raw=False, lang=None, mlist=None): -ADMINDATA = { - # admin keyword: (minimum #args, maximum #args) - 'confirm': (1, 1), - 'help': (0, 0), - 'info': (0, 0), - 'lists': (0, 0), - 'options': (0, 0), - 'password': (2, 2), - 'remove': (0, 0), - 'set': (3, 3), - 'subscribe': (0, 3), - 'unsubscribe': (0, 1), - 'who': (0, 2), - } - -# Given a Message.Message object, test for administrivia (eg subscribe, -# unsubscribe, etc). The test must be a good guess -- messages that return -# true get sent to the list admin instead of the entire list. -def is_administrivia(msg): - linecnt = 0 - lines = [] - for line in email.Iterators.body_line_iterator(msg): - # Strip out any signatures - if line == '-- ': - break - if line.strip(): - linecnt += 1 - if linecnt > config.DEFAULT_MAIL_COMMANDS_MAX_LINES: - return False - lines.append(line) - bodytext = NL.join(lines) - # See if the body text has only one word, and that word is administrivia - if ADMINDATA.has_key(bodytext.strip().lower()): - return True - # Look at the first N lines and see if there is any administrivia on the - # line. BAW: N is currently hardcoded to 5. str-ify the Subject: header - # because it may be an email.Header.Header instance rather than a string. - bodylines = lines[:5] - subject = str(msg.get('subject', '')) - bodylines.append(subject) - for line in bodylines: - if not line.strip(): - continue - words = [word.lower() for word in line.split()] - minargs, maxargs = ADMINDATA.get(words[0], (None, None)) - if minargs is None and maxargs is None: - continue - if minargs <= len(words[1:]) <= maxargs: - # Special case the `set' keyword. BAW: I don't know why this is - # here. - if words[0] == 'set' and words[2] not in ('on', 'off'): - continue - return True - return False - - - def GetRequestURI(fallback=None, escape=True): """Return the full virtual path this CGI script was invoked with. diff --git a/Mailman/app/bounces.py b/Mailman/app/bounces.py index 6df5c8aa6..3f38cbf67 100644 --- a/Mailman/app/bounces.py +++ b/Mailman/app/bounces.py @@ -19,8 +19,6 @@ __all__ = [ 'bounce_message', - 'has_explicit_destination', - 'has_matching_bounce_header', ] import re @@ -62,102 +60,3 @@ def bounce_message(mlist, msg, e=None): 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/chains.py b/Mailman/app/chains.py new file mode 100644 index 000000000..2bf0b9ae1 --- /dev/null +++ b/Mailman/app/chains.py @@ -0,0 +1,115 @@ +# Copyright (C) 2007-2008 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 support for chain processing.""" + +__all__ = [ + 'initialize', + 'process', + ] +__metaclass__ = type + + +from Mailman.chains.accept import AcceptChain +from Mailman.chains.discard import DiscardChain +from Mailman.chains.headers import HeaderMatchChain +from Mailman.chains.hold import HoldChain +from Mailman.chains.reject import RejectChain +from Mailman.chains.builtin import BuiltInChain +from Mailman.configuration import config +from Mailman.interfaces import LinkAction + + + +def process(mlist, msg, msgdata, start_chain='built-in'): + """Process the message through a chain. + + :param start_chain: The name of the chain to start the processing with. + :param mlist: the IMailingList for this message. + :param msg: The Message object. + :param msgdata: The message metadata dictionary. + """ + # Set up some bookkeeping. + chain_stack = [] + msgdata['rule_hits'] = hits = [] + msgdata['rule_misses'] = misses = [] + # Find the starting chain and begin iterating through its links. + chain = config.chains[start_chain] + chain_iter = chain.get_links(mlist, msg, msgdata) + # Loop until we've reached the end of all processing chains. + while chain: + # Iterate over all links in the chain. Do this outside a for-loop so + # we can capture a chain's link iterator in mid-flight. This supports + # the 'detour' link action + try: + link = chain_iter.next() + except StopIteration: + # This chain is exhausted. Pop the last chain on the stack and + # continue iterating through it. If there's nothing left on the + # chain stack then we're completely finished processing. + if len(chain_stack) == 0: + return + chain, chain_iter = chain_stack.pop() + continue + # Process this link. + if link.rule.check(mlist, msg, msgdata): + if link.rule.record: + hits.append(link.rule.name) + # The rule matched so run its action. + if link.action is LinkAction.jump: + chain = link.chain + chain_iter = chain.get_links(mlist, msg, msgdata) + continue + elif link.action is LinkAction.detour: + # Push the current chain so that we can return to it when + # the next chain is finished. + chain_stack.append((chain, chain_iter)) + chain = link.chain + chain_iter = chain.get_links(mlist, msg, msgdata) + continue + elif link.action is LinkAction.stop: + # Stop all processing. + return + elif link.action is LinkAction.defer: + # Just process the next link in the chain. + pass + elif link.action is LinkAction.run: + link.function(mlist, msg, msgdata) + else: + raise AssertionError('Bad link action: %s' % link.action) + else: + # The rule did not match; keep going. + if link.rule.record: + misses.append(link.rule.name) + + + +def initialize(): + """Set up chains, both built-in and from the database.""" + for chain_class in (DiscardChain, HoldChain, RejectChain, AcceptChain): + chain = chain_class() + assert chain.name not in config.chains, ( + 'Duplicate chain name: %s' % chain.name) + config.chains[chain.name] = chain + # Set up a couple of other default chains. + chain = BuiltInChain() + config.chains[chain.name] = chain + # Create and initialize the header matching chain. + chain = HeaderMatchChain() + config.chains[chain.name] = chain + # XXX Read chains from the database and initialize them. + pass diff --git a/Mailman/app/moderator.py b/Mailman/app/moderator.py index 691268153..29577a130 100644 --- a/Mailman/app/moderator.py +++ b/Mailman/app/moderator.py @@ -51,6 +51,18 @@ slog = logging.getLogger('mailman.subscribe') def hold_message(mlist, msg, msgdata=None, reason=None): + """Hold a message for moderator approval. + + The message is added to the mailing list's request database. + + :param mlist: The mailing list to hold the message on. + :param msg: The message to hold. + :param msgdata: Optional message metadata to hold. If not given, a new + metadata dictionary is created and held with the message. + :param reason: Optional string reason why the message is being held. If + not given, the empty string is used. + :return: An id used to handle the held message later. + """ if msgdata is None: msgdata = {} else: @@ -115,10 +127,8 @@ def handle_message(mlist, id, action, if key.startswith('_mod_'): del msgdata[key] # Add some metadata to indicate this message has now been approved. - # XXX 'adminapproved' is used for backward compatibility, but it - # should really be called 'moderator_approved'. msgdata['approved'] = True - msgdata['adminapproved'] = True + msgdata['moderator_approved'] = True # Calculate a new filebase for the approved message, otherwise # delivery errors will cause duplicates. if 'filebase' in msgdata: diff --git a/Mailman/app/replybot.py b/Mailman/app/replybot.py index c46931770..62d442e82 100644 --- a/Mailman/app/replybot.py +++ b/Mailman/app/replybot.py @@ -25,6 +25,7 @@ from __future__ import with_statement __all__ = [ 'autorespond_to_sender', + 'can_acknowledge', ] import logging @@ -87,3 +88,36 @@ def autorespond_to_sender(mlist, sender, lang=None): mlist.hold_and_cmd_autoresponses[sender] = (today, count + 1) return True + + +def can_acknowledge(msg): + """A boolean specifying whether this message can be acknowledged. + + There are several reasons why a message should not be acknowledged, mostly + related to competing standards or common practices. These include: + + * The message has a X-No-Ack header with any value + * The message has an X-Ack header with a 'no' value + * The message has a Precedence header + * The message has an Auto-Submitted header and that header does not have a + value of 'no' + * The message has an empty Return-Path header, e.g. <> + * The message has any RFC 2369 headers (i.e. List-* headers) + + :param msg: a Message object. + :return: Boolean specifying whether the message can be acknowledged or not + (which is different from whether it will be acknowledged). + """ + # I wrote it this way for clarity and consistency with the docstring. + for header in msg.keys(): + if header in ('x-no-ack', 'precedence'): + return False + if header.lower().startswith('list-'): + return False + if msg.get('x-ack', '').lower() == 'no': + return False + if msg.get('auto-submitted', 'no').lower() <> 'no': + return False + if msg.get('return-path') == '<>': + return False + return True diff --git a/Mailman/app/rules.py b/Mailman/app/rules.py new file mode 100644 index 000000000..50bb8d281 --- /dev/null +++ b/Mailman/app/rules.py @@ -0,0 +1,42 @@ +# 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. + +"""Various rule helpers""" + +__all__ = ['initialize'] +__metaclass__ = type + + +from zope.interface import implements +from zope.interface.verify import verifyObject + +from Mailman.app.plugins import get_plugins +from Mailman.configuration import config +from Mailman.interfaces import IRule + + + +def initialize(): + """Find and register all rules in all plugins.""" + # Find rules in plugins. + for rule_finder in get_plugins('mailman.rules'): + for rule_class in rule_finder(): + rule = rule_class() + verifyObject(IRule, rule) + assert rule.name not in config.rules, ( + 'Duplicate rule "%s" found in %s' % (rule.name, rule_finder)) + config.rules[rule.name] = rule diff --git a/Mailman/app/styles.py b/Mailman/app/styles.py index 4ca3f1b01..1c978363e 100644 --- a/Mailman/app/styles.py +++ b/Mailman/app/styles.py @@ -70,7 +70,7 @@ class DefaultStyle: mlist.send_goodbye_msg = config.DEFAULT_SEND_GOODBYE_MSG mlist.bounce_matching_headers = ( config.DEFAULT_BOUNCE_MATCHING_HEADERS) - mlist.header_filter_rules = [] + mlist.header_matches = [] mlist.anonymous_list = config.DEFAULT_ANONYMOUS_LIST mlist.description = u'' mlist.info = u'' @@ -225,6 +225,9 @@ class DefaultStyle: # is that they will get all messages, and they will not have an entry # in this dictionary. mlist.topics_userinterest = {} + # The processing chain that messages coming into this list get + # processed by. + mlist.start_chain = u'built-in' def match(self, mailing_list, styles): # If no other styles have matched, then the default style matches. diff --git a/Mailman/bin/inject.py b/Mailman/bin/inject.py index 60185289f..729038118 100644 --- a/Mailman/bin/inject.py +++ b/Mailman/bin/inject.py @@ -19,11 +19,11 @@ import os import sys import optparse -from Mailman import Post from Mailman import Utils from Mailman import Version from Mailman.configuration import config from Mailman.i18n import _ +from Mailman.inject import inject __i18n_templates__ = True @@ -88,7 +88,7 @@ def main(): else: msgtext = sys.stdin.read() - Post.inject(opts.listname, msgtext, qdir=qdir) + inject(opts.listname, msgtext, qdir=qdir) diff --git a/Mailman/queue/tests/__init__.py b/Mailman/chains/__init__.py index e69de29bb..e69de29bb 100644 --- a/Mailman/queue/tests/__init__.py +++ b/Mailman/chains/__init__.py diff --git a/Mailman/chains/accept.py b/Mailman/chains/accept.py new file mode 100644 index 000000000..a6a9a45eb --- /dev/null +++ b/Mailman/chains/accept.py @@ -0,0 +1,55 @@ +# Copyright (C) 2007-2008 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. + +"""The terminal 'accept' chain.""" + +__all__ = ['AcceptChain'] +__metaclass__ = type + +import logging + +from Mailman.chains.base import TerminalChainBase +from Mailman.configuration import config +from Mailman.i18n import _ +from Mailman.queue import Switchboard + + +log = logging.getLogger('mailman.vette') +SEMISPACE = '; ' + + + +class AcceptChain(TerminalChainBase): + """Accept the message for posting.""" + + name = 'accept' + description = _('Accept a message.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. These metadata could be None or an + # empty list. + rule_hits = msgdata.get('rule_hits') + if rule_hits: + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits) + rule_misses = msgdata.get('rule_misses') + if rule_misses: + msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses) + accept_queue = Switchboard(config.PREPQUEUE_DIR) + accept_queue.enqueue(msg, msgdata) + log.info('ACCEPT: %s', msg.get('message-id', 'n/a')) diff --git a/Mailman/chains/base.py b/Mailman/chains/base.py new file mode 100644 index 000000000..16aa63176 --- /dev/null +++ b/Mailman/chains/base.py @@ -0,0 +1,118 @@ +# Copyright (C) 2008 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. + +"""Base class for terminal chains.""" + +__all__ = [ + 'Chain', + 'Link', + 'TerminalChainBase', + ] +__metaclass__ = type + +from zope.interface import implements + +from Mailman.configuration import config +from Mailman.interfaces import ( + IChain, IChainIterator, IChainLink, IMutableChain, LinkAction) + + + +class Link: + """A chain link.""" + implements(IChainLink) + + def __init__(self, rule, action=None, chain=None, function=None): + self.rule = rule + self.action = (LinkAction.defer if action is None else action) + self.chain = chain + self.function = function + + + +class TerminalChainBase: + """A base chain that always matches and executes a method. + + The method is called 'process' and must be provided by the subclass. + """ + implements(IChain, IChainIterator) + + def _process(self, mlist, msg, msgdata): + """Process the message for the given mailing list. + + This must be overridden by subclasses. + """ + raise NotImplementedError + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + return iter(self) + + def __iter__(self): + """See `IChainIterator`.""" + truth = config.rules['truth'] + # First, yield a link that always runs the process method. + yield Link(truth, LinkAction.run, function=self._process) + # Now yield a rule that stops all processing. + yield Link(truth, LinkAction.stop) + + + +class Chain: + """Generic chain base class.""" + implements(IMutableChain) + + def __init__(self, name, description): + assert name not in config.chains, 'Duplicate chain name: %s' % name + self.name = name + self.description = description + self._links = [] + # Register the chain. + config.chains[name] = self + + def append_link(self, link): + """See `IMutableChain`.""" + self._links.append(link) + + def flush(self): + """See `IMutableChain`.""" + self._links = [] + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + return iter(ChainIterator(self)) + + def get_iterator(self): + """Return an iterator over the links.""" + # We do it this way in order to preserve a separation of interfaces, + # and allows .get_links() to be overridden. + for link in self._links: + yield link + + + +class ChainIterator: + """Generic chain iterator.""" + + implements(IChainIterator) + + def __init__(self, chain): + self._chain = chain + + def __iter__(self): + """See `IChainIterator`.""" + return self._chain.get_iterator() diff --git a/Mailman/chains/builtin.py b/Mailman/chains/builtin.py new file mode 100644 index 000000000..d702b48b9 --- /dev/null +++ b/Mailman/chains/builtin.py @@ -0,0 +1,84 @@ +# Copyright (C) 2007-2008 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. + +"""The default built-in starting chain.""" + +__all__ = ['BuiltInChain'] +__metaclass__ = type + + +import logging + +from zope.interface import implements + +from Mailman.chains.base import Link +from Mailman.configuration import config +from Mailman.i18n import _ +from Mailman.interfaces import IChain, LinkAction + + +log = logging.getLogger('mailman.vette') + + + +class BuiltInChain: + """Default built-in chain.""" + + implements(IChain) + + name = 'built-in' + description = _('The built-in moderation chain.') + + _link_descriptions = ( + ('approved', LinkAction.jump, 'accept'), + ('emergency', LinkAction.jump, 'hold'), + ('loop', LinkAction.jump, 'discard'), + # Do all of the following before deciding whether to hold the message + # for moderation. + ('administrivia', LinkAction.defer, None), + ('implicit-dest', LinkAction.defer, None), + ('max-recipients', LinkAction.defer, None), + ('max-size', LinkAction.defer, None), + ('news-moderation', LinkAction.defer, None), + ('no-subject', LinkAction.defer, None), + ('suspicious-header', LinkAction.defer, None), + # Now if any of the above hit, jump to the hold chain. + ('any', LinkAction.jump, 'hold'), + # Take a detour through the self header matching chain, which we'll + # create later. + ('truth', LinkAction.detour, 'header-match'), + # Finally, the builtin chain selfs to acceptance. + ('truth', LinkAction.jump, 'accept'), + ) + + def __init__(self): + self._cached_links = None + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + if self._cached_links is None: + self._cached_links = links = [] + for rule_name, action, chain_name in self._link_descriptions: + # Get the named rule. + rule = config.rules[rule_name] + # Get the chain, if one is defined. + if chain_name is None: + chain = None + else: + chain = config.chains[chain_name] + links.append(Link(rule, action, chain)) + return iter(self._cached_links) diff --git a/Mailman/chains/discard.py b/Mailman/chains/discard.py new file mode 100644 index 000000000..d4640f260 --- /dev/null +++ b/Mailman/chains/discard.py @@ -0,0 +1,43 @@ +# Copyright (C) 2007-2008 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. + +"""The terminal 'discard' chain.""" + +__all__ = ['DiscardChain'] +__metaclass__ = type + + +import logging + +from Mailman.chains.base import TerminalChainBase +from Mailman.i18n import _ + + +log = logging.getLogger('mailman.vette') + + + +class DiscardChain(TerminalChainBase): + """Discard a message.""" + + name = 'discard' + description = _('Discard a message and stop processing.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + log.info('DISCARD: %s', msg.get('message-id', 'n/a')) + # Nothing more needs to happen. diff --git a/Mailman/chains/headers.py b/Mailman/chains/headers.py new file mode 100644 index 000000000..06dfafeda --- /dev/null +++ b/Mailman/chains/headers.py @@ -0,0 +1,151 @@ +# Copyright (C) 2007-2008 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. + +"""The header-matching chain.""" + +__all__ = ['HeaderMatchChain'] +__metaclass__ = type + + +import re +import logging +import itertools + +from zope.interface import implements + +from Mailman.interfaces import IChainIterator, IRule, LinkAction +from Mailman.chains.base import Chain, Link +from Mailman.i18n import _ +from Mailman.configuration import config + + +log = logging.getLogger('mailman.vette') + + + +def make_link(entry): + """Create a Link object. + + :param entry: a 2- or 3-tuple describing a link. If a 2-tuple, it is a + header and a pattern, and a default chain of 'hold' will be used. If + a 3-tuple, the third item is the chain name to use. + :return: an ILink. + """ + if len(entry) == 2: + header, pattern = entry + chain_name = 'hold' + elif len(entry) == 3: + header, pattern, chain_name = entry + # We don't assert that the chain exists here because the jump + # chain may not yet have been created. + else: + raise AssertionError('Bad link description: %s' % entry) + rule = HeaderMatchRule(header, pattern) + chain = config.chains[chain_name] + return Link(rule, LinkAction.jump, chain) + + + +class HeaderMatchRule: + """Header matching rule used by header-match chain.""" + implements(IRule) + + # Sequential rule counter. + _count = 1 + + def __init__(self, header, pattern): + self._header = header + self._pattern = pattern + self.name = 'header-match-%002d' % HeaderMatchRule._count + HeaderMatchRule._count += 1 + self.description = u'%s: %s' % (header, pattern) + # XXX I think we should do better here, somehow recording that a + # particular header matched a particular pattern, but that gets ugly + # with RFC 2822 headers. It also doesn't match well with the rule + # name concept. For now, we just record the rather useless numeric + # rule name. I suppose we could do the better hit recording in the + # check() method, and set self.record = False. + self.record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + for value in msg.get_all(self._header, []): + if re.search(self._pattern, value, re.IGNORECASE): + return True + return False + + + +class HeaderMatchChain(Chain): + """Default header matching chain. + + This could be extended by header match rules in the database. + """ + + def __init__(self): + super(HeaderMatchChain, self).__init__( + 'header-match', _('The built-in header matching chain')) + # The header match rules are not global, so don't register them. + # These are the only rules that the header match chain can execute. + self._links = [] + # Initialize header check rules with those from the global + # HEADER_MATCHES variable. + for entry in config.HEADER_MATCHES: + self._links.append(make_link(entry)) + # Keep track of how many global header matching rules we've seen. + # This is so the flush() method will only delete those that were added + # via extend() or append_link(). + self._permanent_link_count = len(self._links) + + def extend(self, header, pattern, chain_name='hold'): + """Extend the existing header matches. + + :param header: The case-insensitive header field name. + :param pattern: The pattern to match the header's value again. The + match is not anchored and is done case-insensitively. + :param chain: Option chain to jump to if the pattern matches any of + the named header values. If not given, the 'hold' chain is used. + """ + self._links.append(make_link((header, pattern, chain_name))) + + def flush(self): + """See `IMutableChain`.""" + del self._links[self._permanent_link_count:] + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + list_iterator = HeaderMatchIterator(mlist) + return itertools.chain(iter(self._links), iter(list_iterator)) + + def __iter__(self): + for link in self._links: + yield link + + + +class HeaderMatchIterator: + """An iterator of both the global and list-specific chain links.""" + + implements(IChainIterator) + + def __init__(self, mlist): + self._mlist = mlist + + def __iter__(self): + """See `IChainIterator`.""" + for entry in self._mlist.header_matches: + yield make_link(entry) diff --git a/Mailman/chains/hold.py b/Mailman/chains/hold.py new file mode 100644 index 000000000..7d4c64d45 --- /dev/null +++ b/Mailman/chains/hold.py @@ -0,0 +1,177 @@ +# Copyright (C) 2007-2008 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. + +"""The terminal 'hold' chain.""" + +from __future__ import with_statement + +__all__ = ['HoldChain'] +__metaclass__ = type +__i18n_templates__ = True + + +import logging + +from email.mime.message import MIMEMessage +from email.mime.text import MIMEText +from email.utils import formatdate, make_msgid +from zope.interface import implements + +from Mailman import i18n +from Mailman.Message import UserNotification +from Mailman.Utils import maketext, oneline, wrap, GetCharSet +from Mailman.app.moderator import hold_message +from Mailman.app.replybot import autorespond_to_sender, can_acknowledge +from Mailman.chains.base import TerminalChainBase +from Mailman.configuration import config +from Mailman.interfaces import IPendable + + +log = logging.getLogger('mailman.vette') +SEMISPACE = '; ' +_ = i18n._ + + + +class HeldMessagePendable(dict): + implements(IPendable) + PEND_KEY = 'held message' + + + +class HoldChain(TerminalChainBase): + """Hold a message.""" + + name = 'hold' + description = _('Hold a message and stop processing.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. These metadata could be None or an + # empty list. + rule_hits = msgdata.get('rule_hits') + if rule_hits: + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits) + rule_misses = msgdata.get('rule_misses') + if rule_misses: + msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses) + # Hold the message by adding it to the list's request database. + # XXX How to calculate the reason? + request_id = hold_message(mlist, msg, msgdata, None) + # Calculate a confirmation token to send to the author of the + # message. + pendable = HeldMessagePendable(type=HeldMessagePendable.PEND_KEY, + id=request_id) + token = config.db.pendings.add(pendable) + # Get the language to send the response in. If the sender is a + # member, then send it in the member's language, otherwise send it in + # the mailing list's preferred language. + sender = msg.get_sender() + member = mlist.members.get_member(sender) + language = (member.preferred_language + if member else mlist.preferred_language) + # A substitution dictionary for the email templates. + charset = GetCharSet(mlist.preferred_language) + original_subject = msg.get('subject') + if original_subject is None: + original_subject = _('(no subject)') + else: + original_subject = oneline(original_subject, charset) + substitutions = { + 'listname' : mlist.fqdn_listname, + 'subject' : original_subject, + 'sender' : sender, + 'reason' : 'XXX', #reason, + 'confirmurl' : '%s/%s' % (mlist.script_url('confirm'), token), + 'admindb_url': mlist.script_url('admindb'), + } + # At this point the message is held, but now we have to craft at least + # two responses. The first will go to the original author of the + # message and it will contain the token allowing them to approve or + # discard the message. The second one will go to the moderators of + # the mailing list, if the list is so configured. + # + # Start by possibly sending a response to the message author. There + # are several reasons why we might not go through with this. If the + # message was gated from NNTP, the author may not even know about this + # list, so don't spam them. If the author specifically requested that + # acknowledgments not be sent, or if the message was bulk email, then + # we do not send the response. It's also possible that either the + # mailing list, or the author (if they are a member) have been + # configured to not send such responses. + if (not msgdata.get('fromusenet') and + can_acknowledge(msg) and + mlist.respond_to_post_requests and + autorespond_to_sender(mlist, sender, language)): + # We can respond to the sender with a message indicating their + # posting was held. + subject = _( + 'Your message to $mlist.fqdn_listname awaits moderator approval') + send_language = msgdata.get('lang', language) + text = maketext('postheld.txt', substitutions, + lang=send_language, mlist=mlist) + adminaddr = mlist.bounces_address + nmsg = UserNotification(sender, adminaddr, subject, text, + send_language) + nmsg.send(mlist) + # Now the message for the list moderators. This one should appear to + # come from <list>-owner since we really don't need to do bounce + # processing on it. + if mlist.admin_immed_notify: + # Now let's temporarily set the language context to that which the + # administrators are expecting. + with i18n.using_language(mlist.preferred_language): + language = mlist.preferred_language + charset = GetCharSet(language) + # We need to regenerate or re-translate a few values in the + # substitution dictionary. + #d['reason'] = _(reason) # XXX reason + substitutions['subject'] = original_subject + # craft the admin notification message and deliver it + subject = _( + '$mlist.fqdn_listname post from $sender requires approval') + nmsg = UserNotification(mlist.owner_address, + mlist.owner_address, + subject, lang=language) + nmsg.set_type('multipart/mixed') + text = MIMEText( + maketext('postauth.txt', substitutions, + raw=True, mlist=mlist), + _charset=charset) + dmsg = MIMEText(wrap(_("""\ +If you reply to this message, keeping the Subject: header intact, Mailman will +discard the held message. Do this if the message is spam. If you reply to +this message and include an Approved: header with the list password in it, the +message will be approved for posting to the list. The Approved: header can +also appear in the first line of the body of the reply.""")), + _charset=GetCharSet(language)) + dmsg['Subject'] = 'confirm ' + token + dmsg['Sender'] = mlist.request_address + dmsg['From'] = mlist.request_address + dmsg['Date'] = formatdate(localtime=True) + dmsg['Message-ID'] = make_msgid() + nmsg.attach(text) + nmsg.attach(MIMEMessage(msg)) + nmsg.attach(MIMEMessage(dmsg)) + nmsg.send(mlist, **{'tomoderators': 1}) + # Log the held message + # XXX reason + reason = 'n/a' + log.info('HOLD: %s post from %s held, message-id=%s: %s', + mlist.fqdn_listname, sender, + msg.get('message-id', 'n/a'), reason) diff --git a/Mailman/chains/reject.py b/Mailman/chains/reject.py new file mode 100644 index 000000000..ffec2bdc9 --- /dev/null +++ b/Mailman/chains/reject.py @@ -0,0 +1,55 @@ +# Copyright (C) 2007-2008 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. + +"""The terminal 'reject' chain.""" + +__all__ = ['RejectChain'] +__metaclass__ = type + + +import logging + +from Mailman.app.bounces import bounce_message +from Mailman.chains.base import TerminalChainBase +from Mailman.i18n import _ + + +log = logging.getLogger('mailman.vette') +SEMISPACE = '; ' + + + +class RejectChain(TerminalChainBase): + """Reject/bounce a message.""" + + name = 'reject' + description = _('Reject/bounce a message and stop processing.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. These metadata could be None or an + # empty list. + rule_hits = msgdata.get('rule_hits') + if rule_hits: + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits) + rule_misses = msgdata.get('rule_misses') + if rule_misses: + msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses) + # XXX Exception/reason + bounce_message(mlist, msg) + log.info('REJECT: %s', msg.get('message-id', 'n/a')) diff --git a/Mailman/configuration.py b/Mailman/configuration.py index c2e88affd..5fc359578 100644 --- a/Mailman/configuration.py +++ b/Mailman/configuration.py @@ -128,17 +128,18 @@ class Configuration(object): self.PRIVATE_ARCHIVE_FILE_DIR = join(VAR_DIR, 'archives', 'private') # Directories used by the qrunner subsystem self.QUEUE_DIR = qdir = join(VAR_DIR, 'qfiles') - self.INQUEUE_DIR = join(qdir, 'in') - self.OUTQUEUE_DIR = join(qdir, 'out') - self.CMDQUEUE_DIR = join(qdir, 'commands') + self.ARCHQUEUE_DIR = join(qdir, 'archive') + self.BADQUEUE_DIR = join(qdir, 'bad') self.BOUNCEQUEUE_DIR = join(qdir, 'bounces') + self.CMDQUEUE_DIR = join(qdir, 'commands') + self.INQUEUE_DIR = join(qdir, 'in') + self.MAILDIR_DIR = join(qdir, 'maildir') self.NEWSQUEUE_DIR = join(qdir, 'news') - self.ARCHQUEUE_DIR = join(qdir, 'archive') + self.OUTQUEUE_DIR = join(qdir, 'out') + self.PREPQUEUE_DIR = join(qdir, 'prepare') + self.RETRYQUEUE_DIR = join(qdir, 'retry') self.SHUNTQUEUE_DIR = join(qdir, 'shunt') self.VIRGINQUEUE_DIR = join(qdir, 'virgin') - self.BADQUEUE_DIR = join(qdir, 'bad') - self.RETRYQUEUE_DIR = join(qdir, 'retry') - self.MAILDIR_DIR = join(qdir, 'maildir') self.MESSAGES_DIR = join(VAR_DIR, 'messages') # Other useful files self.PIDFILE = join(datadir, 'master-qrunner.pid') @@ -174,6 +175,9 @@ class Configuration(object): # Always add and enable the default server language. code = self.DEFAULT_SERVER_LANGUAGE self.languages.enable_language(code) + # Create the registry of rules and chains. + self.chains = {} + self.rules = {} def add_domain(self, email_host, url_host=None): """Add a virtual domain. diff --git a/Mailman/database/mailinglist.py b/Mailman/database/mailinglist.py index 04c872aab..b3eb56003 100644 --- a/Mailman/database/mailinglist.py +++ b/Mailman/database/mailinglist.py @@ -113,7 +113,7 @@ class MailingList(Model): gateway_to_news = Bool() generic_nonmember_action = Int() goodbye_msg = Unicode() - header_filter_rules = Pickle() + header_matches = Pickle() hold_these_nonmembers = Pickle() include_list_post_header = Bool() include_rfc2369_headers = Bool() @@ -151,6 +151,7 @@ class MailingList(Model): send_goodbye_msg = Bool() send_reminders = Bool() send_welcome_msg = Bool() + start_chain = Unicode() subject_prefix = Unicode() subscribe_auto_approval = Pickle() subscribe_policy = Int() @@ -215,7 +216,7 @@ class MailingList(Model): return self.fqdn_listname @property - def noreply_address(self): + def no_reply_address(self): return '%s@%s' % (config.NO_REPLY_ADDRESS, self.host_name) @property diff --git a/Mailman/database/mailman.sql b/Mailman/database/mailman.sql index cff4daba0..7c53f25be 100644 --- a/Mailman/database/mailman.sql +++ b/Mailman/database/mailman.sql @@ -93,7 +93,7 @@ CREATE TABLE mailinglist ( gateway_to_news BOOLEAN, generic_nonmember_action INTEGER, goodbye_msg TEXT, - header_filter_rules BLOB, + header_matches BLOB, hold_these_nonmembers BLOB, include_list_post_header BOOLEAN, include_rfc2369_headers BOOLEAN, @@ -131,6 +131,7 @@ CREATE TABLE mailinglist ( send_goodbye_msg BOOLEAN, send_reminders BOOLEAN, send_welcome_msg BOOLEAN, + start_chain TEXT, subject_prefix TEXT, subscribe_auto_approval BLOB, subscribe_policy INTEGER, @@ -145,6 +146,7 @@ CREATE TABLE member ( id INTEGER NOT NULL, role TEXT, mailing_list TEXT, + is_moderated BOOLEAN, address_id INTEGER, preferences_id INTEGER, PRIMARY KEY (id), diff --git a/Mailman/database/member.py b/Mailman/database/member.py index f77b8c7c3..b24688423 100644 --- a/Mailman/database/member.py +++ b/Mailman/database/member.py @@ -33,6 +33,7 @@ class Member(Model): id = Int(primary=True) role = Enum() mailing_list = Unicode() + is_moderated = Bool() address_id = Int() address = Reference(address_id, 'Address.id') @@ -43,6 +44,7 @@ class Member(Model): self.role = role self.mailing_list = mailing_list self.address = address + self.is_moderated = False def __repr__(self): return '<Member: %s on %s as %s>' % ( diff --git a/Mailman/database/model.py b/Mailman/database/model.py index 3b41af123..d0cc8b6e8 100644 --- a/Mailman/database/model.py +++ b/Mailman/database/model.py @@ -45,8 +45,7 @@ class ModelMeta(PropertyPublisherMeta): @staticmethod def _reset(store): for model_class in ModelMeta._class_registry: - for row in store.find(model_class): - store.remove(row) + store.find(model_class).remove() diff --git a/Mailman/database/pending.py b/Mailman/database/pending.py index 1a272391d..c3f54c814 100644 --- a/Mailman/database/pending.py +++ b/Mailman/database/pending.py @@ -112,6 +112,10 @@ class Pendings(object): value = u'__builtin__.float\1%s' % value elif type(value) is bool: value = u'__builtin__.bool\1%s' % value + elif type(value) is list: + # We expect this to be a list of strings. + value = u'Mailman.database.pending.unpack_list\1%s' % ( + '\2'.join(value)) keyval = PendedKeyValue(key=key, value=value) pending.key_values.add(keyval) config.db.store.add(pending) @@ -156,3 +160,8 @@ class Pendings(object): for keyvalue in q: store.remove(keyvalue) store.remove(pending) + + + +def unpack_list(value): + return value.split('\2') diff --git a/Mailman/docs/antispam.txt b/Mailman/docs/antispam.txt deleted file mode 100644 index 3ad5e982e..000000000 --- a/Mailman/docs/antispam.txt +++ /dev/null @@ -1,75 +0,0 @@ -Anti-spam defences -================== - -By design, Mailman does not have very sophisticated anti-spam measures because -this type of filtering is done much more efficiently at the MTA level. For -example, if Mailman were to do spam detection, it could not reject the message -at SMTP time. - -Still, Mailman does employ a small number of rather ham-handed anti-spam -measures. - - >>> from Mailman.Handlers.SpamDetect import process - >>> from Mailman.queue import Switchboard - >>> from Mailman.configuration import config - >>> mlist = config.db.list_manager.create(u'_xtest@example.com') - - -Short circuiting ----------------- - -If a message is pre-approved, this handler does nothing. - - >>> msg = message_from_string("""\ - ... From: aperson@example.com - ... - ... An important message. - ... """) - >>> msgdata = {'approved': True} - >>> process(mlist, msg, msgdata) - >>> print msg.as_string() - From: aperson@example.com - <BLANKLINE> - An important message. - <BLANKLINE> - >>> msgdata - {'approved': True} - - -Header matching ---------------- - -There is a global configuration variable that can be set to a list of header -matches. Each item in that list is a 2-tuple of the header to match and a -regular expression. For example, if we wanted to block all message that come -from 'aperson' regardless of the domain, we'd do something like the following -in our mailman.cfg file: - - >>> old_value = config.KNOWN_SPAMMERS[:] - >>> config.KNOWN_SPAMMERS.append(('from', 'aperson')) - -Now if the same message is posted to the mailing list, and that message is not -pre-approved. The handler will throw an exception that signals the message is -spam. - - >>> msgdata = {} - >>> process(mlist, msg, msgdata) - Traceback (most recent call last): - ... - SpamDetected - >>> print msg.as_string() - From: aperson@example.com - <BLANKLINE> - An important message. - <BLANKLINE> - >>> msgdata - {} - - # Restore global state - config.KNOWN_SPAMMERS = old_value - - -Header filter rules -------------------- - -XXX Need tests. diff --git a/Mailman/docs/chains.txt b/Mailman/docs/chains.txt new file mode 100644 index 000000000..433ee8e8e --- /dev/null +++ b/Mailman/docs/chains.txt @@ -0,0 +1,340 @@ +Chains +====== + +When a new message comes into the system, Mailman uses a set of rule chains to +decide whether the message gets posted to the list, rejected, discarded, or +held for moderator approval. + +There are a number of built-in chains available that act as end-points in the +processing of messages. + + +The Discard chain +----------------- + +The Discard chain simply throws the message away. + + >>> from zope.interface.verify import verifyObject + >>> from Mailman.configuration import config + >>> from Mailman.interfaces import IChain + >>> chain = config.chains['discard'] + >>> verifyObject(IChain, chain) + True + >>> chain.name + 'discard' + >>> chain.description + u'Discard a message and stop processing.' + + >>> from Mailman.app.lifecycle import create_list + >>> mlist = create_list(u'_xtest@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: My first post + ... Message-ID: <first> + ... + ... An important message. + ... """) + + >>> from Mailman.app.chains import process + + # XXX This checks the vette log file because there is no other evidence + # that this chain has done anything. + >>> import os + >>> fp = open(os.path.join(config.LOG_DIR, 'vette')) + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'discard') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <first> + <BLANKLINE> + + +The Reject chain +---------------- + +The Reject chain bounces the message back to the original sender, and logs +this action. + + >>> chain = config.chains['reject'] + >>> verifyObject(IChain, chain) + True + >>> chain.name + 'reject' + >>> chain.description + u'Reject/bounce a message and stop processing.' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'reject') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... REJECT: <first> + +The bounce message is now sitting in the Virgin queue. + + >>> from Mailman.queue import Switchboard + >>> virginq = Switchboard(config.VIRGINQUEUE_DIR) + >>> len(virginq.files) + 1 + >>> qmsg, qdata = virginq.dequeue(virginq.files[0]) + >>> print qmsg.as_string() + Subject: My first post + From: _xtest-owner@example.com + To: aperson@example.com + ... + [No bounce details are available] + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + <BLANKLINE> + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + <BLANKLINE> + An important message. + <BLANKLINE> + ... + + +The Hold Chain +-------------- + +The Hold chain places the message into the admin request database and +depending on the list's settings, sends a notification to both the original +sender and the list moderators. + + >>> mlist.web_page_url = u'http://www.example.com/' + >>> chain = config.chains['hold'] + >>> verifyObject(IChain, chain) + True + >>> chain.name + 'hold' + >>> chain.description + u'Hold a message and stop processing.' + + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'hold') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... HOLD: _xtest@example.com post from aperson@example.com held, + message-id=<first>: n/a + <BLANKLINE> + +There are now two messages in the Virgin queue, one to the list moderators and +one to the original author. + + >>> len(virginq.files) + 2 + >>> qfiles = [] + >>> for filebase in virginq.files: + ... qmsg, qdata = virginq.dequeue(filebase) + ... virginq.finish(filebase) + ... qfiles.append(qmsg) + >>> from operator import itemgetter + >>> qfiles.sort(key=itemgetter('to')) + +This message is addressed to the mailing list moderators. + + >>> print qfiles[0].as_string() + Subject: _xtest@example.com post from aperson@example.com requires approval + From: _xtest-owner@example.com + To: _xtest-owner@example.com + MIME-Version: 1.0 + ... + As list administrator, your authorization is requested for the + following mailing list posting: + <BLANKLINE> + List: _xtest@example.com + From: aperson@example.com + Subject: My first post + Reason: XXX + <BLANKLINE> + At your convenience, visit: + <BLANKLINE> + http://www.example.com/admindb/_xtest@example.com + <BLANKLINE> + to approve or deny the request. + <BLANKLINE> + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + <BLANKLINE> + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + <BLANKLINE> + An important message. + <BLANKLINE> + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + <BLANKLINE> + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Subject: confirm ... + Sender: _xtest-request@example.com + From: _xtest-request@example.com + ... + <BLANKLINE> + If you reply to this message, keeping the Subject: header intact, + Mailman will discard the held message. Do this if the message is + spam. If you reply to this message and include an Approved: header + with the list password in it, the message will be approved for posting + to the list. The Approved: header can also appear in the first line + of the body of the reply. + ... + +This message is addressed to the sender of the message. + + >>> print qfiles[1].as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: Your message to _xtest@example.com awaits moderator approval + From: _xtest-bounces@example.com + To: aperson@example.com + ... + Your mail to '_xtest@example.com' with the subject + <BLANKLINE> + My first post + <BLANKLINE> + Is being held until the list moderator can review it for approval. + <BLANKLINE> + The reason it is being held: + <BLANKLINE> + XXX + <BLANKLINE> + Either the message will get posted to the list, or you will receive + notification of the moderator's decision. If you would like to cancel + this posting, please visit the following URL: + <BLANKLINE> + http://www.example.com/confirm/_xtest@example.com/... + <BLANKLINE> + <BLANKLINE> + +In addition, the pending database is holding the original messages, waiting +for them to be disposed of by the original author or the list moderators. The +database is essentially a dictionary, with the keys being the randomly +selected tokens included in the urls and the values being a 2-tuple where the +first item is a type code and the second item is a message id. + + >>> import re + >>> cookie = None + >>> for line in qfiles[1].get_payload().splitlines(): + ... mo = re.search('confirm/[^/]+/(?P<cookie>.*)$', line) + ... if mo: + ... cookie = mo.group('cookie') + ... break + >>> assert cookie is not None, 'No confirmation token found' + >>> data = config.db.pendings.confirm(cookie) + >>> sorted(data.items()) + [(u'id', ...), (u'type', u'held message')] + +The message itself is held in the message store. + + >>> rkey, rdata = config.db.requests.get_list_requests(mlist).get_request( + ... data['id']) + >>> msg = config.db.message_store.get_message_by_id( + ... rdata['_mod_message_id']) + >>> print msg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + <BLANKLINE> + An important message. + <BLANKLINE> + + +The Accept chain +---------------- + +The Accept chain sends the message on the 'prep' queue, where it will be +processed and sent on to the list membership. + + >>> chain = config.chains['accept'] + >>> verifyObject(IChain, chain) + True + >>> chain.name + 'accept' + >>> chain.description + u'Accept a message.' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'accept') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... ACCEPT: <first> + + >>> prepq = Switchboard(config.PREPQUEUE_DIR) + >>> len(prepq.files) + 1 + >>> qmsg, qdata = prepq.dequeue(prepq.files[0]) + >>> print qmsg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + <BLANKLINE> + An important message. + <BLANKLINE> + + +Run-time chains +--------------- + +We can also define chains at run time, and these chains can be mutated. +Run-time chains are made up of links where each link associates both a rule +and a 'jump'. The rule is really a rule name, which is looked up when +needed. The jump names a chain which is jumped to if the rule matches. + +There is one built-in run-time chain, called appropriately 'built-in'. This +is the default chain to use when no other input chain is defined for a mailing +list. It runs through the default rules, providing functionality similar to +the Hold handler from previous versions of Mailman. + + >>> chain = config.chains['built-in'] + >>> verifyObject(IChain, chain) + True + >>> chain.name + 'built-in' + >>> chain.description + u'The built-in moderation chain.' + +The previously created message is innocuous enough that it should pass through +all default rules. This message will end up in the prep queue. + + >>> file_pos = fp.tell() + >>> from Mailman.app.chains import process + >>> process(mlist, msg, {}) + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... ACCEPT: <first> + + >>> qmsg, qdata = prepq.dequeue(prepq.files[0]) + >>> print qmsg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + X-Mailman-Rule-Misses: approved; emergency; loop; administrivia; implicit-dest; + max-recipients; max-size; news-moderation; no-subject; + suspicious-header + <BLANKLINE> + An important message. + <BLANKLINE> + +In addition, the message metadata now contains lists of all rules that have +hit and all rules that have missed. + + >>> sorted(qdata['rule_hits']) + [] + >>> sorted(qdata['rule_misses']) + ['administrivia', 'approved', 'emergency', 'implicit-dest', 'loop', + 'max-recipients', 'max-size', 'news-moderation', 'no-subject', + 'suspicious-header'] diff --git a/Mailman/docs/hold.txt b/Mailman/docs/hold.txt deleted file mode 100644 index a93953435..000000000 --- a/Mailman/docs/hold.txt +++ /dev/null @@ -1,362 +0,0 @@ -Holding messages -================ - -One of the most important functions of Mailman is to moderate messages by -holding some for approval before they will post to the mailing list. Messages -are held when they meet any of a number of criteria. - - >>> import os - >>> import errno - >>> from Mailman.Handlers.Hold import process - >>> from Mailman.queue import Switchboard - >>> from Mailman.configuration import config - >>> mlist = config.db.list_manager.create(u'_xtest@example.com') - >>> mlist.preferred_language = u'en' - >>> mlist.real_name = u'_XTest' - >>> # XXX This will almost certainly change once we've worked out the web - >>> # space layout for mailing lists now. - >>> mlist.web_page_url = u'http://lists.example.com/' - -Here's a helper function used when we don't care about what's in the virgin -queue or in the pending database. - - >>> switchboard = Switchboard(config.VIRGINQUEUE_DIR) - >>> def clear(): - ... for filebase in switchboard.files: - ... msg, msgdata = switchboard.dequeue(filebase) - ... switchboard.finish(filebase) - ... for holdfile in os.listdir(config.DATA_DIR): - ... if holdfile.startswith('heldmsg-'): - ... os.unlink(os.path.join(config.DATA_DIR, holdfile)) - ... try: - ... os.unlink(os.path.join(config.DATA_DIR, 'pending.db')) - ... except OSError, e: - ... if e.errno <> errno.ENOENT: - ... raise - - -Short circuiting ----------------- - -If the message metadata indicates that the message is pre-approved, then the -handler returns immediately. - - >>> msg = message_from_string("""\ - ... From: aperson@example.com - ... - ... An important message. - ... """) - >>> msgdata = {'approved': True} - >>> process(mlist, msg, msgdata) - >>> print msg.as_string() - From: aperson@example.com - <BLANKLINE> - An important message. - <BLANKLINE> - >>> msgdata - {'approved': True} - - -Administrivia -------------- - -Mailman scans parts of the message for administrivia, meaning text that looks -like an email command. This is to prevent people sending 'help' or -'subscribe' message, etc. to the list members. First, we enable the scanning -of administrivia for the list. - - >>> mlist.administrivia = True - >>> msg = message_from_string("""\ - ... From: aperson@example.com - ... Subject: unsubscribe - ... - ... """) - >>> process(mlist, msg, {}) - Traceback (most recent call last): - ... - Administrivia - >>> clear() - - -Maximum number of recipients ----------------------------- - -Mailman will hold messages that have more than a specified number of explicit -recipients. - - >>> mlist.max_num_recipients = 5 - >>> msg = message_from_string("""\ - ... From: aperson@example.com - ... To: _xtest@example.com, bperson@example.com - ... Cc: cperson@example.com - ... Cc: dperson@example.com (Dan Person) - ... To: Elly Q. Person <eperson@example.com> - ... - ... Hey folks! - ... """) - >>> process(mlist, msg, {}) - Traceback (most recent call last): - ... - TooManyRecipients - >>> clear() - - -Implicit destination --------------------- - -Mailman will hold messages that have implicit destination, meaning that the -mailing list's posting address isn't included in the explicit recipients. - - >>> mlist.require_explicit_destination = True - >>> mlist.acceptable_aliases = u'' - >>> msg = message_from_string("""\ - ... From: aperson@example.org - ... Subject: An implicit message - ... - ... """) - >>> process(mlist, msg, {}) - Traceback (most recent call last): - ... - ImplicitDestination - >>> clear() - -A message gated from NNTP will obviously have an implicit destination. Such -gated messages will not be held for implicit destination because it's assumed -that Mailman pulled it from the appropriate news group. - - >>> msgdata = {'fromusenet': True} - >>> process(mlist, msg, msgdata) - >>> print msg.as_string() - From: aperson@example.org - Subject: An implicit message - Message-ID: ... - X-Message-ID-Hash: ... - <BLANKLINE> - <BLANKLINE> - >>> print msgdata - {'fromusenet': True} - - -Suspicious headers ------------------- - -Suspicious headers are a way for Mailman to hold messages that match a -particular regular expression. This mostly historical feature is fairly -confusing to users, and the list attribute that controls this is misnamed. - - >>> mlist.bounce_matching_headers = u'From: .*person@(blah.)?example.com' - >>> msg = message_from_string("""\ - ... From: aperson@example.com - ... To: _xtest@example.com - ... Subject: An implicit message - ... - ... """) - >>> process(mlist, msg, {}) - Traceback (most recent call last): - ... - SuspiciousHeaders - >>> clear() - -But if the header doesn't match the regular expression, it'll get posted just -fine. This one comes from a .org address. - - >>> msg = message_from_string("""\ - ... From: aperson@example.org - ... To: _xtest@example.com - ... Subject: An implicit message - ... - ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) - >>> print msgdata - {} - -Just a bit of clean up. - - >>> mlist.bounce_matching_headers = None - - -Message size ------------- - -Mailman can hold messages that are bigger than a given size. Generally this -is used to prevent huge attachments from getting posted to the list. This -value is calculated in terms of KB (1024 bytes). - - >>> mlist.max_message_size = 1 - >>> one_line = 'x' * 79 - >>> big_body = '\n'.join([one_line] * 15) - >>> msg = message_from_string("""\ - ... From: aperson@example.com - ... To: _xtest@example.com - ... - ... """ + big_body) - >>> process(mlist, msg, {}) - Traceback (most recent call last): - ... - MessageTooBig - >>> clear() - - -Hold Notifications ------------------- - -Whenever Mailman holds a message, it sends notifications both to the list -owner and to the original sender, as long as it is configured to do so. We -can show this by first holding a message. - - >>> mlist.respond_to_post_requests = True - >>> mlist.admin_immed_notify = True - >>> msg = message_from_string("""\ - ... From: aperson@example.com - ... - ... """) - >>> process(mlist, msg, {}) - Traceback (most recent call last): - ... - ImplicitDestination - -There should be two messages in the virgin queue, one to the list owner and -one to the original author. - - >>> len(switchboard.files) - 2 - >>> qfiles = {} - >>> for filebase in switchboard.files: - ... qmsg, qdata = switchboard.dequeue(filebase) - ... switchboard.finish(filebase) - ... qfiles[qmsg['to']] = qmsg, qdata - >>> qmsg, qdata = qfiles['_xtest-owner@example.com'] - >>> print qmsg.as_string() - Subject: _xtest post from aperson@example.com requires approval - From: _xtest-owner@example.com - To: _xtest-owner@example.com - MIME-Version: 1.0 - Content-Type: multipart/mixed; boundary="..." - Message-ID: ... - Date: ... - Precedence: bulk - <BLANKLINE> - --... - Content-Type: text/plain; charset="us-ascii" - MIME-Version: 1.0 - Content-Transfer-Encoding: 7bit - <BLANKLINE> - As list administrator, your authorization is requested for the - following mailing list posting: - <BLANKLINE> - List: _xtest@example.com - From: aperson@example.com - Subject: (no subject) - Reason: Message has implicit destination - <BLANKLINE> - At your convenience, visit: - <BLANKLINE> - http://lists.example.com/admindb/_xtest@example.com - <BLANKLINE> - to approve or deny the request. - <BLANKLINE> - --... - Content-Type: message/rfc822 - MIME-Version: 1.0 - <BLANKLINE> - From: aperson@example.com - Message-ID: ... - X-Message-ID-Hash: ... - <BLANKLINE> - <BLANKLINE> - --... - Content-Type: message/rfc822 - MIME-Version: 1.0 - <BLANKLINE> - Content-Type: text/plain; charset="us-ascii" - MIME-Version: 1.0 - Content-Transfer-Encoding: 7bit - Subject: confirm ... - Sender: _xtest-request@example.com - From: _xtest-request@example.com - Date: ... - Message-ID: ... - <BLANKLINE> - If you reply to this message, keeping the Subject: header intact, - Mailman will discard the held message. Do this if the message is - spam. If you reply to this message and include an Approved: header - with the list password in it, the message will be approved for posting - to the list. The Approved: header can also appear in the first line - of the body of the reply. - --... - >>> sorted(qdata.items()) - [('_parsemsg', False), ('listname', u'_xtest@example.com'), - ('nodecorate', True), ('received_time', ...), - ('recips', [u'_xtest-owner@example.com']), - ('reduced_list_headers', True), - ('tomoderators', 1), ('version', 3)] - >>> qmsg, qdata = qfiles['aperson@example.com'] - >>> print qmsg.as_string() - MIME-Version: 1.0 - Content-Type: text/plain; charset="us-ascii" - Content-Transfer-Encoding: 7bit - Subject: Your message to _xtest awaits moderator approval - From: _xtest-bounces@example.com - To: aperson@example.com - Message-ID: ... - Date: ... - Precedence: bulk - <BLANKLINE> - Your mail to '_xtest' with the subject - <BLANKLINE> - (no subject) - <BLANKLINE> - Is being held until the list moderator can review it for approval. - <BLANKLINE> - The reason it is being held: - <BLANKLINE> - Message has implicit destination - <BLANKLINE> - Either the message will get posted to the list, or you will receive - notification of the moderator's decision. If you would like to cancel - this posting, please visit the following URL: - <BLANKLINE> - http://lists.example.com/confirm/_xtest@example.com/... - <BLANKLINE> - <BLANKLINE> - >>> sorted(qdata.items()) - [('_parsemsg', False), ('listname', u'_xtest@example.com'), - ('nodecorate', True), ('received_time', ...), - ('recips', [u'aperson@example.com']), - ('reduced_list_headers', True), ('version', 3)] - -In addition, the pending database is holding the original messages, waiting -for them to be disposed of by the original author or the list moderators. The -database is essentially a dictionary, with the keys being the randomly -selected tokens included in the urls and the values being a 2-tuple where the -first item is a type code and the second item is a message id. - - >>> import re - >>> cookie = None - >>> qmsg, qdata = qfiles['aperson@example.com'] - >>> for line in qmsg.get_payload().splitlines(): - ... mo = re.search('confirm/[^/]+/(?P<cookie>.*)$', line) - ... if mo: - ... cookie = mo.group('cookie') - ... break - >>> data = config.db.pendings.confirm(cookie) - >>> sorted(data.items()) - [(u'id', ...), (u'type', u'held message')] - -The message itself is held in the message store. - - >>> rkey, rdata = config.db.requests.get_list_requests(mlist).get_request( - ... data['id']) - >>> msg = config.db.message_store.get_message_by_id( - ... rdata['_mod_message_id']) - >>> print msg.as_string() - From: aperson@example.com - Message-ID: ... - X-Message-ID-Hash: ... - <BLANKLINE> - <BLANKLINE> - -Clean up. - - >>> clear() diff --git a/Mailman/docs/mlist-addresses.txt b/Mailman/docs/mlist-addresses.txt index dc2184175..4685a6eea 100644 --- a/Mailman/docs/mlist-addresses.txt +++ b/Mailman/docs/mlist-addresses.txt @@ -18,7 +18,7 @@ list. This is exactly the same as the fully qualified list name. Messages to the mailing list's 'no reply' address always get discarded without prejudice. - >>> mlist.noreply_address + >>> mlist.no_reply_address u'noreply@example.com' The mailing list's owner address reaches the human moderators. diff --git a/Mailman/docs/requests.txt b/Mailman/docs/requests.txt index ea4dcc75d..6f7dd2f14 100644 --- a/Mailman/docs/requests.txt +++ b/Mailman/docs/requests.txt @@ -318,7 +318,7 @@ indicates that the message has been approved. <BLANKLINE> >>> sorted(qdata.items()) [('_parsemsg', False), - ('adminapproved', True), (u'approved', True), + (u'approved', True), ('moderator_approved', True), (u'received_time', 123.45), (u'sender', u'aperson@example.com'), ('version', 3)] diff --git a/Mailman/initialize.py b/Mailman/initialize.py index dff2677f3..3577ebad6 100644 --- a/Mailman/initialize.py +++ b/Mailman/initialize.py @@ -63,6 +63,12 @@ def initialize_2(debug=False): verifyObject(IDatabase, database) database.initialize(debug) Mailman.configuration.config.db = database + # Initialize the rules and chains. Do the imports here so as to avoid + # circular imports. + from Mailman.app.chains import initialize as initialize_chains + from Mailman.app.rules import initialize as initialize_rules + initialize_rules() + initialize_chains() def initialize(config_path=None, propagate_logs=False): diff --git a/Mailman/inject.py b/Mailman/inject.py new file mode 100644 index 000000000..a17f8c7d1 --- /dev/null +++ b/Mailman/inject.py @@ -0,0 +1,34 @@ +# Copyright (C) 2001-2008 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. + +from Mailman.configuration import config +from Mailman.queue import Switchboard + + + +def inject(listname, msg, recips=None, qdir=None): + if qdir is None: + qdir = config.INQUEUE_DIR + queue = Switchboard(qdir) + kws = dict( + listname=listname, + tolist=True, + _plaintext=True, + ) + if recips is not None: + kws['recips'] = recips + queue.enqueue(msg, **kws) diff --git a/Mailman/interfaces/__init__.py b/Mailman/interfaces/__init__.py index c4094e365..20dff7fdf 100644 --- a/Mailman/interfaces/__init__.py +++ b/Mailman/interfaces/__init__.py @@ -46,7 +46,12 @@ def _populate(): is_enum = issubclass(obj, Enum) except TypeError: is_enum = False - if IInterface.providedBy(obj) or is_enum: + is_interface = IInterface.providedBy(obj) + try: + is_exception = issubclass(obj, Exception) + except TypeError: + is_exception = False + if is_interface or is_exception or is_enum: setattr(iface_mod, name, obj) __all__.append(name) diff --git a/Mailman/interfaces/chain.py b/Mailman/interfaces/chain.py new file mode 100644 index 000000000..04c0260c2 --- /dev/null +++ b/Mailman/interfaces/chain.py @@ -0,0 +1,98 @@ +# 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. + +"""Interface describing the basics of chains and links.""" + +from munepy import Enum +from zope.interface import Interface, Attribute + + + +class LinkAction(Enum): + # Jump to another chain. + jump = 0 + # Take a detour to another chain, returning to the original chain when + # completed (if no other jump occurs). + detour = 1 + # Stop processing all chains. + stop = 2 + # Continue processing the next link in the chain. + defer = 3 + # Run a function and continue processing. + run = 4 + + + +class IChainLink(Interface): + """A link in the chain.""" + + rule = Attribute('The rule to run for this link.') + + action = Attribute('The LinkAction to take if this rule matches.') + + chain = Attribute('The chain to jump or detour to.') + + function = Attribute( + """The function to execute. + + The function takes three arguments and returns nothing. + :param mlist: the IMailingList object + :param msg: the message being processed + :param msgdata: the message metadata dictionary + """) + + + +class IChain(Interface): + """A chain of rules.""" + + name = Attribute('Chain name; must be unique.') + description = Attribute('A brief description of the chain.') + + def get_links(mlist, msg, msgdata): + """Get an `IChainIterator` for processing. + + :param mlist: the IMailingList object + :param msg: the message being processed + :param msgdata: the message metadata dictionary + :return: An `IChainIterator`. + """ + + + +class IChainIterator(Interface): + """An iterator over chain rules.""" + + def __iter__(): + """Iterate over all the IChainLinks in this chain. + + :return: an IChainLink. + """ + + + +class IMutableChain(IChain): + """Like `IChain` but can be mutated.""" + + def append_link(link): + """Add a new chain link to the end of this chain. + + :param link: The chain link to add. + """ + + def flush(): + """Delete all links in this chain.""" diff --git a/Mailman/interfaces/mailinglist.py b/Mailman/interfaces/mailinglist.py index 2d3811785..2c828a43c 100644 --- a/Mailman/interfaces/mailinglist.py +++ b/Mailman/interfaces/mailinglist.py @@ -82,7 +82,7 @@ class IMailingList(Interface): delivery is currently enabled. """) - noreply_address = Attribute( + no_reply_address = Attribute( """The address to which all messages will be immediately discarded, without prejudice or record. This address is specific to the ddomain, even though it's available on the IMailingListAddresses interface. diff --git a/Mailman/interfaces/member.py b/Mailman/interfaces/member.py index 18f0b034e..8fc410a77 100644 --- a/Mailman/interfaces/member.py +++ b/Mailman/interfaces/member.py @@ -78,6 +78,9 @@ class IMember(Interface): role = Attribute( """The role of this membership.""") + is_moderated = Attribute( + """True if the membership is moderated, otherwise False.""") + def unsubscribe(): """Unsubscribe (and delete) this member from the mailing list.""" diff --git a/Mailman/interfaces/rules.py b/Mailman/interfaces/rules.py new file mode 100644 index 000000000..e92b354e1 --- /dev/null +++ b/Mailman/interfaces/rules.py @@ -0,0 +1,45 @@ +# 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. + +"""Interface describing the basics of rules.""" + +from zope.interface import Interface, Attribute + + + +class IRule(Interface): + """A basic rule.""" + + name = Attribute('Rule name; must be unique.') + + description = Attribute('A brief description of the rule.') + + record = Attribute( + """Should this rule's success or failure be recorded? + + This is a boolean; if True then this rule's hit or miss will be + recorded in a message header. If False, it won't. + """) + + def check(mlist, msg, msgdata): + """Run the rule. + + :param mlist: The mailing list object. + :param msg: The message object. + :param msgdata: The message metadata. + :returns: a boolean specifying whether the rule matched or not. + """ diff --git a/Mailman/queue/__init__.py b/Mailman/queue/__init__.py index c415834ba..6a1873d60 100644 --- a/Mailman/queue/__init__.py +++ b/Mailman/queue/__init__.py @@ -140,7 +140,13 @@ class Switchboard: msg = cPickle.load(fp) data = cPickle.load(fp) if data.get('_parsemsg'): + # Calculate the original size of the text now so that we won't + # have to generate the message later when we do size restriction + # checking. + original_size = len(msg) msg = email.message_from_string(msg, Message.Message) + msg.original_size = original_size + data['original_size'] = original_size return msg, data def finish(self, filebase, preserve=False): diff --git a/Mailman/queue/command.py b/Mailman/queue/command.py index dfbfd1255..8628411de 100644 --- a/Mailman/queue/command.py +++ b/Mailman/queue/command.py @@ -88,8 +88,8 @@ class Results: assert isinstance(body, basestring) lines = body.splitlines() # Use no more lines than specified - self.commands.extend(lines[:config.DEFAULT_MAIL_COMMANDS_MAX_LINES]) - self.ignored.extend(lines[config.DEFAULT_MAIL_COMMANDS_MAX_LINES:]) + self.commands.extend(lines[:config.EMAIL_COMMANDS_MAX_LINES]) + self.ignored.extend(lines[config.EMAIL_COMMANDS_MAX_LINES:]) def process(self): # Now, process each line until we find an error. The first diff --git a/Mailman/queue/docs/OVERVIEW.txt b/Mailman/queue/docs/OVERVIEW.txt new file mode 100644 index 000000000..643fa8a5c --- /dev/null +++ b/Mailman/queue/docs/OVERVIEW.txt @@ -0,0 +1,78 @@ +Alias overview +============== + +A typical Mailman list exposes nine aliases which point to seven different +wrapped scripts. E.g. for a list named `mylist', you'd have: + + mylist-bounces -> bounces + mylist-confirm -> confirm + mylist-join -> join (-subscribe is an alias) + mylist-leave -> leave (-unsubscribe is an alias) + mylist-owner -> owner + mylist -> post + mylist-request -> request + +-request, -join, and -leave are a robot addresses; their sole purpose is to +process emailed commands, although the latter two are hardcoded to +subscription and unsubscription requests. -bounces is the automated bounce +processor, and all messages to list members have their return address set to +-bounces. If the bounce processor fails to extract a bouncing member address, +it can optionally forward the message on to the list owners. + +-owner is for reaching a human operator with minimal list interaction (i.e. no +bounce processing). -confirm is another robot address which processes replies +to VERP-like confirmation notices. + +So delivery flow of messages look like this: + + joerandom ---> mylist ---> list members + | | + | |[bounces] + | mylist-bounces <---+ <-------------------------------+ + | | | + | +--->[internal bounce processing] | + | ^ | | + | | | [bounce found] | + | [bounces *] +--->[register and discard] | + | | | | | + | | | |[*] | + | [list owners] |[no bounce found] | | + | ^ | | | + | | | | | + +-------> mylist-owner <--------+ | | + | | | + | data/owner-bounces.mbox <--[site list] <---+ | + | | + +-------> mylist-join--+ | + | | | + +------> mylist-leave--+ | + | | | + | v | + +-------> mylist-request | + | | | + | +---> [command processor] | + | | | + +-----> mylist-confirm ----> +---> joerandom | + | | + |[bounces] | + +----------------------+ + +A person can send an email to the list address (for posting), the -owner +address (to reach the human operator), or the -confirm, -join, -leave, and +-request mailbots. Message to the list address are then forwarded on to the +list membership, with bounces directed to the -bounces address. + +[*] Messages sent to the -owner address are forwarded on to the list +owner/moderators. All -owner destined messages have their bounces directed to +the site list -bounces address, regardless of whether a human sent the message +or the message was crafted internally. The intention here is that the site +owners want to be notified when one of their list owners' addresses starts +bouncing (yes, the will be automated in a future release). + +Any messages to site owners has their bounces directed to a special +"loop-killer" address, which just dumps the message into +data/owners-bounces.mbox. + +Finally, message to any of the mailbots causes the requested action to be +performed. Results notifications are sent to the author of the message, which +all bounces pointing back to the -bounces address. diff --git a/Mailman/queue/docs/incoming.txt b/Mailman/queue/docs/incoming.txt new file mode 100644 index 000000000..04c0cfa04 --- /dev/null +++ b/Mailman/queue/docs/incoming.txt @@ -0,0 +1,198 @@ +The incoming queue runner +========================= + +This runner's sole purpose in life is to decide the disposition of the +message. It can either be accepted for delivery, rejected (i.e. bounced), +held for moderator approval, or discarded. + +The runner operates by processing chains on a message/metadata pair in the +context of a mailing list. Each mailing list may have a 'start chain' where +processing begins, with a global default. This chain is processed with the +message eventually ending up in one of the four disposition states described +above. + + >>> from Mailman.app.lifecycle import create_list + >>> mlist = create_list(u'_xtest@example.com') + >>> mlist.start_chain + u'built-in' + + +Accepted messages +----------------- + +We have a message that is going to be sent to the mailing list. This message +is so perfectly fine for posting that it will be accepted and forward to the +prep queue. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: My first post + ... Message-ID: <first> + ... + ... First post! + ... """) + +Normally, the upstream mail server would drop the message in the incoming +queue, but this is an effective simulation. + + >>> from Mailman.inject import inject + >>> inject(u'_xtest@example.com', msg) + +The incoming queue runner runs until it is empty. + + >>> from Mailman.queue.incoming import IncomingRunner + >>> from Mailman.tests.helpers import make_testable_runner + >>> incoming = make_testable_runner(IncomingRunner) + >>> incoming.run() + +And now the message is in the prep queue. + + >>> from Mailman.configuration import config + >>> from Mailman.queue import Switchboard + >>> prep_queue = Switchboard(config.PREPQUEUE_DIR) + >>> len(prep_queue.files) + 1 + >>> incoming_queue = Switchboard(config.INQUEUE_DIR) + >>> len(incoming_queue.files) + 0 + >>> from Mailman.tests.helpers import get_queue_messages + >>> item = get_queue_messages(prep_queue)[0] + >>> print item.msg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Mailman-Rule-Misses: approved; emergency; loop; administrivia; + implicit-dest; + max-recipients; max-size; news-moderation; no-subject; + suspicious-header + <BLANKLINE> + First post! + <BLANKLINE> + >>> sorted(item.msgdata.items()) + [...('envsender', u'noreply@example.com')...('tolist', True)...] + + +Held messages +------------- + +The list moderator sets the emergency flag on the mailing list. The built-in +chain will now hold all posted messages, so nothing will show up in the prep +queue. + + # XXX This checks the vette log file because there is no other evidence + # that this chain has done anything. + >>> import os + >>> fp = open(os.path.join(config.LOG_DIR, 'vette')) + >>> fp.seek(0, 2) + + >>> mlist.emergency = True + >>> mlist.web_page_url = u'http://archives.example.com/' + >>> inject(u'_xtest@example.com', msg) + >>> file_pos = fp.tell() + >>> incoming.run() + >>> len(prep_queue.files) + 0 + >>> len(incoming_queue.files) + 0 + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... HOLD: _xtest@example.com post from aperson@example.com held, + message-id=<first>: n/a + <BLANKLINE> + + >>> mlist.emergency = False + + +Discarded messages +------------------ + +Another possibility is that the message would get immediately discarded. The +built-in chain does not have such a disposition by default, so let's craft a +new chain and set it as the mailing list's start chain. + + >>> from Mailman.chains.base import Chain, Link + >>> from Mailman.interfaces import LinkAction + >>> truth_rule = config.rules['truth'] + >>> discard_chain = config.chains['discard'] + >>> test_chain = Chain('always-discard', u'Testing discards') + >>> link = Link(truth_rule, LinkAction.jump, discard_chain) + >>> test_chain.append_link(link) + >>> mlist.start_chain = u'always-discard' + + >>> inject(u'_xtest@example.com', msg) + >>> file_pos = fp.tell() + >>> incoming.run() + >>> len(prep_queue.files) + 0 + >>> len(incoming_queue.files) + 0 + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <first> + <BLANKLINE> + + >>> del config.chains['always-discard'] + + +Rejected messages +----------------- + +Similar to discarded messages, a message can be rejected, or bounced back to +the original sender. Again, the built-in chain doesn't support this so we'll +just create a new chain that does. + + >>> reject_chain = config.chains['reject'] + >>> test_chain = Chain('always-reject', u'Testing rejections') + >>> link = Link(truth_rule, LinkAction.jump, reject_chain) + >>> test_chain.append_link(link) + >>> mlist.start_chain = u'always-reject' + +The virgin queue needs to be cleared out due to artifacts from the previous +tests above. + + >>> virgin_queue = Switchboard(config.VIRGINQUEUE_DIR) + >>> ignore = get_queue_messages(virgin_queue) + + >>> inject(u'_xtest@example.com', msg) + >>> file_pos = fp.tell() + >>> incoming.run() + >>> len(prep_queue.files) + 0 + >>> len(incoming_queue.files) + 0 + + >>> len(virgin_queue.files) + 1 + >>> item = get_queue_messages(virgin_queue)[0] + >>> print item.msg.as_string() + Subject: My first post + From: _xtest-owner@example.com + To: aperson@example.com + ... + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + <BLANKLINE> + [No bounce details are available] + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + <BLANKLINE> + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + <BLANKLINE> + First post! + <BLANKLINE> + ... + >>> sorted(item.msgdata.items()) + [...('recips', [u'aperson@example.com'])...] + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... REJECT: <first> + <BLANKLINE> + + >>> del config.chains['always-reject'] diff --git a/Mailman/docs/news-runner.txt b/Mailman/queue/docs/news.txt index bc6619f50..bc6619f50 100644 --- a/Mailman/docs/news-runner.txt +++ b/Mailman/queue/docs/news.txt diff --git a/Mailman/docs/outgoing.txt b/Mailman/queue/docs/outgoing.txt index ba2c6430b..ba2c6430b 100644 --- a/Mailman/docs/outgoing.txt +++ b/Mailman/queue/docs/outgoing.txt diff --git a/Mailman/docs/runner.txt b/Mailman/queue/docs/runner.txt index 5e5a88d8c..5e5a88d8c 100644 --- a/Mailman/docs/runner.txt +++ b/Mailman/queue/docs/runner.txt diff --git a/Mailman/docs/switchboard.txt b/Mailman/queue/docs/switchboard.txt index 299aba499..299aba499 100644 --- a/Mailman/docs/switchboard.txt +++ b/Mailman/queue/docs/switchboard.txt diff --git a/Mailman/queue/incoming.py b/Mailman/queue/incoming.py index 6118a7ca0..649ce2213 100644 --- a/Mailman/queue/incoming.py +++ b/Mailman/queue/incoming.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2007 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2008 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 @@ -12,102 +12,26 @@ # # 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. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. -"""Incoming queue runner.""" - -# A typical Mailman list exposes nine aliases which point to seven different -# wrapped scripts. E.g. for a list named `mylist', you'd have: -# -# mylist-bounces -> bounces (-admin is a deprecated alias) -# mylist-confirm -> confirm -# mylist-join -> join (-subscribe is an alias) -# mylist-leave -> leave (-unsubscribe is an alias) -# mylist-owner -> owner -# mylist -> post -# mylist-request -> request -# -# -request, -join, and -leave are a robot addresses; their sole purpose is to -# process emailed commands in a Majordomo-like fashion (although the latter -# two are hardcoded to subscription and unsubscription requests). -bounces is -# the automated bounce processor, and all messages to list members have their -# return address set to -bounces. If the bounce processor fails to extract a -# bouncing member address, it can optionally forward the message on to the -# list owners. -# -# -owner is for reaching a human operator with minimal list interaction -# (i.e. no bounce processing). -confirm is another robot address which -# processes replies to VERP-like confirmation notices. -# -# So delivery flow of messages look like this: -# -# joerandom ---> mylist ---> list members -# | | -# | |[bounces] -# | mylist-bounces <---+ <-------------------------------+ -# | | | -# | +--->[internal bounce processing] | -# | ^ | | -# | | | [bounce found] | -# | [bounces *] +--->[register and discard] | -# | | | | | -# | | | |[*] | -# | [list owners] |[no bounce found] | | -# | ^ | | | -# | | | | | -# +-------> mylist-owner <--------+ | | -# | | | -# | data/owner-bounces.mbox <--[site list] <---+ | -# | | -# +-------> mylist-join--+ | -# | | | -# +------> mylist-leave--+ | -# | | | -# | v | -# +-------> mylist-request | -# | | | -# | +---> [command processor] | -# | | | -# +-----> mylist-confirm ----> +---> joerandom | -# | | -# |[bounces] | -# +----------------------+ -# -# A person can send an email to the list address (for posting), the -owner -# address (to reach the human operator), or the -confirm, -join, -leave, and -# -request mailbots. Message to the list address are then forwarded on to the -# list membership, with bounces directed to the -bounces address. -# -# [*] Messages sent to the -owner address are forwarded on to the list -# owner/moderators. All -owner destined messages have their bounces directed -# to the site list -bounces address, regardless of whether a human sent the -# message or the message was crafted internally. The intention here is that -# the site owners want to be notified when one of their list owners' addresses -# starts bouncing (yes, the will be automated in a future release). -# -# Any messages to site owners has their bounces directed to a special -# "loop-killer" address, which just dumps the message into -# data/owners-bounces.mbox. -# -# Finally, message to any of the mailbots causes the requested action to be -# performed. Results notifications are sent to the author of the message, -# which all bounces pointing back to the -bounces address. +"""Incoming queue runner. +This runner's sole purpose in life is to decide the disposition of the +message. It can either be accepted for delivery, rejected (i.e. bounced), +held for moderator approval, or discarded. - -import os -import sys -import logging +When accepted, the message is forwarded on to the `prep queue` where it is +prepared for delivery. Rejections, discards, and holds are processed +immediately. +""" -from cStringIO import StringIO -from Mailman import Errors + +from Mailman.app.chains import process from Mailman.configuration import config from Mailman.queue import Runner -log = logging.getLogger('mailman.error') -vlog = logging.getLogger('mailman.vette') - class IncomingRunner(Runner): @@ -115,59 +39,8 @@ class IncomingRunner(Runner): def _dispose(self, mlist, msg, msgdata): if msgdata.get('envsender') is None: - msg['envsender'] = mlist.no_reply_address - # Process the message through a handler pipeline. The handler - # pipeline can actually come from one of three places: the message - # metadata, the mlist, or the global pipeline. - # - # If a message was requeued due to an uncaught exception, its metadata - # will contain the retry pipeline. Use this above all else. - # Otherwise, if the mlist has a `pipeline' attribute, it should be - # used. Final fallback is the global pipeline. - pipeline = self._get_pipeline(mlist, msg, msgdata) - msgdata['pipeline'] = pipeline - more = self._dopipeline(mlist, msg, msgdata, pipeline) - if not more: - del msgdata['pipeline'] - config.db.commit() - return more - - # Overridable - def _get_pipeline(self, mlist, msg, msgdata): - # We must return a copy of the list, otherwise, the first message that - # flows through the pipeline will empty it out! - return msgdata.get('pipeline', - getattr(mlist, 'pipeline', - config.GLOBAL_PIPELINE))[:] - - def _dopipeline(self, mlist, msg, msgdata, pipeline): - while pipeline: - handler = pipeline.pop(0) - modname = 'Mailman.Handlers.' + handler - __import__(modname) - try: - pid = os.getpid() - sys.modules[modname].process(mlist, msg, msgdata) - # Failsafe -- a child may have leaked through. - if pid <> os.getpid(): - log.error('child process leaked thru: %s', modname) - os._exit(1) - except Errors.DiscardMessage: - # Throw the message away; we need do nothing else with it. - vlog.info('Message discarded, msgid: %s', - msg.get('message-id', 'n/a')) - return 0 - except Errors.HoldMessage: - # Let the approval process take it from here. The message no - # longer needs to be queued. - return 0 - except Errors.RejectMessage, e: - mlist.bounce_message(msg, e) - return 0 - except: - # Push this pipeline module back on the stack, then re-raise - # the exception. - pipeline.insert(0, handler) - raise - # We've successfully completed handling of this message - return 0 + msgdata['envsender'] = mlist.no_reply_address + # Process the message through the mailing list's start chain. + process(mlist, msg, msgdata, mlist.start_chain) + # Do not keep this message queued. + return False diff --git a/Mailman/rules/__init__.py b/Mailman/rules/__init__.py new file mode 100644 index 000000000..a78e9bd05 --- /dev/null +++ b/Mailman/rules/__init__.py @@ -0,0 +1,50 @@ +# 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. + +"""The built in rule set.""" + +__all__ = ['initialize'] +__metaclass__ = type + + +import os +import sys + +from Mailman.interfaces import IRule + + + +def initialize(): + """Initialize the built-in rules. + + Rules are auto-discovered by searching for IRule implementations in all + importable modules in this subpackage. + """ + # Find all rules found in all modules inside our package. + import Mailman.rules + here = os.path.dirname(Mailman.rules.__file__) + for filename in os.listdir(here): + basename, extension = os.path.splitext(filename) + if extension <> '.py': + continue + module_name = 'Mailman.rules.' + basename + __import__(module_name, fromlist='*') + module = sys.modules[module_name] + for name in module.__all__: + rule = getattr(module, name) + if IRule.implementedBy(rule): + yield rule diff --git a/Mailman/rules/administrivia.py b/Mailman/rules/administrivia.py new file mode 100644 index 000000000..76848fecf --- /dev/null +++ b/Mailman/rules/administrivia.py @@ -0,0 +1,98 @@ +# Copyright (C) 2007-2008 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. + +"""The administrivia rule.""" + +__all__ = ['Administrivia'] +__metaclass__ = type + + +from email.iterators import typed_subpart_iterator +from zope.interface import implements + +from Mailman.configuration import config +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + +# The list of email commands we search for in the Subject header and payload. +# We probably should get this information from the actual implemented +# commands. +EMAIL_COMMANDS = { + # keyword: (minimum #args, maximum #args) + 'confirm': (1, 1), + 'help': (0, 0), + 'info': (0, 0), + 'lists': (0, 0), + 'options': (0, 0), + 'password': (2, 2), + 'remove': (0, 0), + 'set': (3, 3), + 'subscribe': (0, 3), + 'unsubscribe': (0, 1), + 'who': (0, 2), + } + + + +class Administrivia: + """The administrivia rule.""" + implements(IRule) + + name = 'administrivia' + description = _('Catch mis-addressed email commands.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # The list must have the administrivia check enabled. + if not mlist.administrivia: + return False + # First check the Subject text. + lines_to_check = [] + subject = msg.get('subject', '') + if subject <> '': + lines_to_check.append(subject) + # Search only the first text/plain subpart of the message. There's + # really no good way to find email commands in any other content type. + for part in typed_subpart_iterator(msg, 'text', 'plain'): + payload = part.get_payload(decode=True) + lines = payload.splitlines() + # Count lines without using enumerate() because blank lines in the + # payload don't count against the maximum examined. + lineno = 0 + for line in lines: + line = line.strip() + if line == '': + continue + lineno += 1 + if lineno > config.EMAIL_COMMANDS_MAX_LINES: + break + lines_to_check.append(line) + # Only look at the first text/plain part. + break + # For each line we're checking, split the line into words. Then see + # if it looks like a command with the min-to-max number of arguments. + for line in lines_to_check: + words = [word.lower() for word in line.split()] + if words[0] not in EMAIL_COMMANDS: + # This is not an administrivia command. + continue + minargs, maxargs = EMAIL_COMMANDS[words[0]] + if minargs <= len(words) - 1 <= maxargs: + return True + return False diff --git a/Mailman/rules/any.py b/Mailman/rules/any.py new file mode 100644 index 000000000..c0755b58f --- /dev/null +++ b/Mailman/rules/any.py @@ -0,0 +1,41 @@ +# Copyright (C) 2007-2008 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. + +"""Check if any previous rules have matched.""" + +__all__ = ['Any'] +__metaclass__ = type + + +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class Any: + """Look for any previous rule match.""" + implements(IRule) + + name = 'any' + description = _('Look for any previous rule hit.') + record = False + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return len(msgdata.get('rules', [])) > 0 diff --git a/Mailman/rules/approved.py b/Mailman/rules/approved.py new file mode 100644 index 000000000..f3c1dc412 --- /dev/null +++ b/Mailman/rules/approved.py @@ -0,0 +1,117 @@ +# Copyright (C) 2007-2008 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. + +"""Look for moderator pre-approval.""" + +__all__ = ['Approved'] +__metaclass__ = type + + +import re +from email.iterators import typed_subpart_iterator +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + +EMPTYSTRING = u'' + + + +class Approved: + """Look for moderator pre-approval.""" + implements(IRule) + + name = 'approved' + description = _('The message has a matching Approve or Approved header.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # See if the message has an Approved or Approve header with a valid + # moderator password. Also look at the first non-whitespace line in + # the file to see if it looks like an Approved header. + missing = object() + password = msg.get('approved', msg.get('approve', missing)) + if password is missing: + # Find the first text/plain part in the message + part = None + stripped = False + for part in typed_subpart_iterator(msg, 'text', 'plain'): + break + payload = part.get_payload(decode=True) + if payload is not None: + lines = payload.splitlines(True) + for lineno, line in enumerate(lines): + if line.strip() <> '': + break + if ':' in line: + header, value = line.split(':', 1) + if header.lower() in ('approved', 'approve'): + password = value.strip() + # Now strip the first line from the payload so the + # password doesn't leak. + del lines[lineno] + reset_payload(part, EMPTYSTRING.join(lines)) + stripped = True + if stripped: + # Now try all the text parts in case it's + # multipart/alternative with the approved line in HTML or + # other text part. We make a pattern from the Approved line + # and delete it from all text/* parts in which we find it. It + # would be better to just iterate forward, but email + # compatability for pre Python 2.2 returns a list, not a true + # iterator. + # + # This will process all the multipart/alternative parts in the + # message as well as all other text parts. We shouldn't find + # the pattern outside the multipart/alternative parts, but if + # we do, it is probably best to delete it anyway as it does + # contain the password. + # + # Make a pattern to delete. We can't just delete a line + # because line of HTML or other fancy text may include + # additional message text. This pattern works with HTML. It + # may not work with rtf or whatever else is possible. + pattern = header + ':(\s| )*' + re.escape(password) + for part in typed_subpart_iterator(msg, 'text'): + payload = part.get_payload(decode=True) + if payload is not None: + if re.search(pattern, payload): + reset_payload(part, re.sub(pattern, '', payload)) + else: + del msg['approved'] + del msg['approve'] + return password is not missing and password == mlist.moderator_password + + + +def reset_payload(part, payload): + # Set decoded payload maintaining content-type, charset, format and delsp. + charset = part.get_content_charset() or 'us-ascii' + content_type = part.get_content_type() + format = part.get_param('format') + delsp = part.get_param('delsp') + del part['content-transfer-encoding'] + del part['content-type'] + part.set_payload(payload, charset) + part.set_type(content_type) + if format: + part.set_param('Format', format) + if delsp: + part.set_param('DelSp', delsp) diff --git a/Mailman/rules/docs/administrivia.txt b/Mailman/rules/docs/administrivia.txt new file mode 100644 index 000000000..de802fa85 --- /dev/null +++ b/Mailman/rules/docs/administrivia.txt @@ -0,0 +1,100 @@ +Administrivia +============= + +The 'administrivia' rule matches when the message contains some common email +commands in the Subject header or first few lines of the payload. This is +used to catch messages posted to the list which should have been sent to the +-request robot address. + + >>> from Mailman.configuration import config + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist.administrivia = True + >>> rule = config.rules['administrivia'] + >>> rule.name + 'administrivia' + +For example, if the Subject header contains the word 'unsubscribe', the rule +matches. + + >>> msg_1 = message_from_string(u"""\ + ... From: aperson@example.com + ... Subject: unsubscribe + ... + ... """) + >>> rule.check(mlist, msg_1, {}) + True + +Similarly, if the body of the message contains the word 'subscribe' in the +first few lines of text, the rule matches. + + >>> msg_2 = message_from_string(u"""\ + ... From: aperson@example.com + ... Subject: I wish to join your list + ... + ... subscribe + ... """) + >>> rule.check(mlist, msg_2, {}) + True + +In both cases, administrivia checking can be disabled. + + >>> mlist.administrivia = False + >>> rule.check(mlist, msg_1, {}) + False + >>> rule.check(mlist, msg_2, {}) + False + +To make the administrivia heuristics a little more robust, the rule actually +looks for a minimum and maximum number of arguments, so that it really does +seem like a mis-addressed email command. In this case, the 'confirm' command +requires at least one argument. We don't give that here so the rule will not +match. + + >>> mlist.administrivia = True + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... Subject: confirm + ... + ... """) + >>> rule.check(mlist, msg, {}) + False + +But a real 'confirm' message will match. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... Subject: confirm 12345 + ... + ... """) + >>> rule.check(mlist, msg, {}) + True + +We don't show all the other possible email commands, but you get the idea. + + +Non-administrivia +----------------- + +Of course, messages that don't contain administrivia, don't match the rule. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... Subject: examine + ... + ... persuade + ... """) + >>> rule.check(mlist, msg, {}) + False + +Also, only text/plain parts are checked for administrivia, so any email +commands in other content type subparts are ignored. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... Subject: some administrivia + ... Content-Type: text/x-special + ... + ... subscribe + ... """) + >>> rule.check(mlist, msg, {}) + False diff --git a/Mailman/docs/approve.txt b/Mailman/rules/docs/approve.txt index 56afc1dd4..32367a76b 100644 --- a/Mailman/docs/approve.txt +++ b/Mailman/rules/docs/approve.txt @@ -13,98 +13,71 @@ approval queue. This has several use cases: In order to support this, a mailing list can be given a 'moderator password' which is shared among all the administrators. - >>> from Mailman.Handlers.Approve import process >>> from Mailman.configuration import config >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist.moderator_password = u'abcxyz' + +The 'approved' rule determines whether the message contains the proper +approval or not. + >>> rule = config.rules['approved'] + >>> rule.name + 'approved' -Short circuiting ----------------- -The message may have been approved by some other means, as evident in the -message metadata. In this case, the handler returns immediately. +No approval +----------- + +If the message has no Approve or Approved header, then the rule does not +match. >>> msg = message_from_string(u"""\ ... From: aperson@example.com ... ... An important message. ... """) - >>> msgdata = {'approved': True} - >>> process(mlist, msg, msgdata) - >>> print msg.as_string() - From: aperson@example.com - <BLANKLINE> - An important message. - <BLANKLINE> - >>> msgdata - {'approved': True} + >>> rule.check(mlist, msg, {}) + False +If the message has an Approve or Approved header with a value that does not +match the moderator password, then the rule does not match. However, the +header is still removed. -The Approved header -------------------- + >>> msg['Approve'] = u'12345' + >>> rule.check(mlist, msg, {}) + False + >>> print msg['approve'] + None -If the moderator password is given in an Approved header, then the message -gets sent through with no further posting moderation. The Approved header is -not stripped in this handler module, but instead in the Cleanse module. This -ensures that no moderator approval password in the headers will leak out. + >>> del msg['approve'] + >>> msg['Approved'] = u'12345' + >>> rule.check(mlist, msg, {}) + False + >>> print msg['approved'] + None - >>> mlist.moderator_password = u'abcxyz' - >>> msg['Approved'] = u'abcxyz' - >>> msgdata = {} - >>> process(mlist, msg, msgdata) - >>> print msg.as_string() - From: aperson@example.com - Approved: abcxyz - <BLANKLINE> - An important message. - <BLANKLINE> - >>> sorted(msgdata.items()) - [('adminapproved', True), ('approved', True)] + >>> del msg['approved'] -But if the wrong password is given, then the message is not marked as being -approved. The header is still removed though. - >>> del msg['Approved'] - >>> msg['Approved'] = u'123456' - >>> msgdata = {} - >>> process(mlist, msg, msgdata) - >>> print msg.as_string() - From: aperson@example.com - Approved: 123456 - <BLANKLINE> - An important message. - <BLANKLINE> - >>> msgdata - {} +Using an approval header +------------------------ -In the spirit of being liberal in what you accept, using an Approve header is -completely synonymous. +If the moderator password is given in an Approve header, then the rule +matches, and the Approve header is stripped. - >>> del msg['Approved'] >>> msg['Approve'] = u'abcxyz' - >>> msgdata = {} - >>> process(mlist, msg, msgdata) - >>> print msg.as_string() - From: aperson@example.com - Approve: abcxyz - <BLANKLINE> - An important message. - <BLANKLINE> - >>> sorted(msgdata.items()) - [('adminapproved', True), ('approved', True)] + >>> rule.check(mlist, msg, {}) + True + >>> print msg['approve'] + None - >>> del msg['Approve'] - >>> msg['Approve'] = u'123456' - >>> msgdata = {} - >>> process(mlist, msg, msgdata) - >>> print msg.as_string() - From: aperson@example.com - Approve: 123456 - <BLANKLINE> - An important message. - <BLANKLINE> - >>> msgdata - {} +Similarly, for the Approved header. + + >>> msg['Approved'] = u'abcxyz' + >>> rule.check(mlist, msg, {}) + True + >>> print msg['approved'] + None Using a pseudo-header @@ -113,17 +86,20 @@ Using a pseudo-header Different mail user agents have varying degrees to which they support custom headers like Approve and Approved. For this reason, Mailman also supports using a 'pseudo-header', which is really just the first non-whitespace line in -the payload of the message of the message. If this pseudo-header looks like a -matching Approve or Approved header, the message is similarly allowed to pass. +the payload of the message. If this pseudo-header looks like a matching +Approve or Approved header, the message is similarly allowed to pass. >>> msg = message_from_string(u"""\ ... From: aperson@example.com ... - ... Approved: abcxyz + ... Approve: abcxyz ... An important message. ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) + >>> rule.check(mlist, msg, {}) + True + +The pseudo-header is removed. + >>> print msg.as_string() From: aperson@example.com Content-Transfer-Encoding: 7bit @@ -132,17 +108,18 @@ matching Approve or Approved header, the message is similarly allowed to pass. <BLANKLINE> An important message. <BLANKLINE> - >>> sorted(msgdata.items()) - [('adminapproved', True), ('approved', True)] + +Similarly for the Approved header. >>> msg = message_from_string(u"""\ ... From: aperson@example.com ... - ... Approve: abcxyz + ... Approved: abcxyz ... An important message. ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) + >>> rule.check(mlist, msg, {}) + True + >>> print msg.as_string() From: aperson@example.com Content-Transfer-Encoding: 7bit @@ -151,8 +128,6 @@ matching Approve or Approved header, the message is similarly allowed to pass. <BLANKLINE> An important message. <BLANKLINE> - >>> sorted(msgdata.items()) - [('adminapproved', True), ('approved', True)] As before, a mismatch in the pseudo-header does not approve the message, but the pseudo-header line is still removed. @@ -160,11 +135,12 @@ the pseudo-header line is still removed. >>> msg = message_from_string(u"""\ ... From: aperson@example.com ... - ... Approved: 123456 + ... Approve: 123456 ... An important message. ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) + >>> rule.check(mlist, msg, {}) + False + >>> print msg.as_string() From: aperson@example.com Content-Transfer-Encoding: 7bit @@ -173,17 +149,18 @@ the pseudo-header line is still removed. <BLANKLINE> An important message. <BLANKLINE> - >>> msgdata - {} + +Similarly for the Approved header. >>> msg = message_from_string(u"""\ ... From: aperson@example.com ... - ... Approve: 123456 + ... Approved: 123456 ... An important message. ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) + >>> rule.check(mlist, msg, {}) + False + >>> print msg.as_string() From: aperson@example.com Content-Transfer-Encoding: 7bit @@ -192,8 +169,6 @@ the pseudo-header line is still removed. <BLANKLINE> An important message. <BLANKLINE> - >>> msgdata - {} MIME multipart support @@ -211,18 +186,21 @@ used with MIME documents. ... --AAA ... Content-Type: application/x-ignore ... - ... Approved: 123456 + ... Approve: 123456 ... The above line will be ignored. ... ... --AAA ... Content-Type: text/plain ... - ... Approved: abcxyz + ... Approve: abcxyz ... An important message. ... --AAA-- ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) + >>> rule.check(mlist, msg, {}) + True + +Like before, the pseudo-header is removed, but only from the text parts. + >>> print msg.as_string() From: aperson@example.com MIME-Version: 1.0 @@ -231,7 +209,7 @@ used with MIME documents. --AAA Content-Type: application/x-ignore <BLANKLINE> - Approved: 123456 + Approve: 123456 The above line will be ignored. <BLANKLINE> --AAA @@ -242,8 +220,8 @@ used with MIME documents. An important message. --AAA-- <BLANKLINE> - >>> sorted(msgdata.items()) - [('adminapproved', True), ('approved', True)] + +The same goes for the Approved message. >>> msg = message_from_string(u"""\ ... From: aperson@example.com @@ -253,18 +231,21 @@ used with MIME documents. ... --AAA ... Content-Type: application/x-ignore ... - ... Approve: 123456 + ... Approved: 123456 ... The above line will be ignored. ... ... --AAA ... Content-Type: text/plain ... - ... Approve: abcxyz + ... Approved: abcxyz ... An important message. ... --AAA-- ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) + >>> rule.check(mlist, msg, {}) + True + +And the header is removed. + >>> print msg.as_string() From: aperson@example.com MIME-Version: 1.0 @@ -273,7 +254,7 @@ used with MIME documents. --AAA Content-Type: application/x-ignore <BLANKLINE> - Approve: 123456 + Approved: 123456 The above line will be ignored. <BLANKLINE> --AAA @@ -284,8 +265,6 @@ used with MIME documents. An important message. --AAA-- <BLANKLINE> - >>> sorted(msgdata.items()) - [('adminapproved', True), ('approved', True)] Here, the correct password is in the non-text/plain part, so it is ignored. @@ -307,8 +286,11 @@ Here, the correct password is in the non-text/plain part, so it is ignored. ... An important message. ... --AAA-- ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) + >>> rule.check(mlist, msg, {}) + False + +And yet the pseudo-header is still stripped. + >>> print msg.as_string() From: aperson@example.com MIME-Version: 1.0 @@ -327,8 +309,8 @@ Here, the correct password is in the non-text/plain part, so it is ignored. <BLANKLINE> An important message. --AAA-- - >>> msgdata - {} + +As before, the same goes for the Approved header. >>> msg = message_from_string(u"""\ ... From: aperson@example.com @@ -338,18 +320,21 @@ Here, the correct password is in the non-text/plain part, so it is ignored. ... --AAA ... Content-Type: application/x-ignore ... - ... Approve: abcxyz + ... Approved: abcxyz ... The above line will be ignored. ... ... --AAA ... Content-Type: text/plain ... - ... Approve: 123456 + ... Approved: 123456 ... An important message. ... --AAA-- ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) + >>> rule.check(mlist, msg, {}) + False + +And the pseudo-header is removed. + >>> print msg.as_string() From: aperson@example.com MIME-Version: 1.0 @@ -358,7 +343,7 @@ Here, the correct password is in the non-text/plain part, so it is ignored. --AAA Content-Type: application/x-ignore <BLANKLINE> - Approve: abcxyz + Approved: abcxyz The above line will be ignored. <BLANKLINE> --AAA @@ -368,8 +353,14 @@ Here, the correct password is in the non-text/plain part, so it is ignored. <BLANKLINE> An important message. --AAA-- - >>> msgdata - {} + + +Stripping text/html parts +------------------------- + +Because some mail readers will include both a text/plain part and a text/html +alternative, the 'approved' rule has to search the alternatives and strip +anything that looks like an Approve or Approved headers. >>> msg = message_from_string(u"""\ ... From: aperson@example.com @@ -377,30 +368,100 @@ Here, the correct password is in the non-text/plain part, so it is ignored. ... Content-Type: multipart/mixed; boundary="AAA" ... ... --AAA - ... Content-Type: application/x-ignore + ... Content-Type: text/html + ... + ... <html> + ... <head></head> + ... <body> + ... <b>Approved: abcxyz</b> + ... <p>The above line will be ignored. + ... </body> + ... </html> + ... + ... --AAA + ... Content-Type: text/plain ... ... Approved: abcxyz - ... The above line will be ignored. + ... An important message. + ... --AAA-- + ... """) + >>> rule.check(mlist, msg, {}) + True + +And the header-like text in the text/html part was stripped. + + >>> print msg.as_string() + From: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="AAA" + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/html; charset="us-ascii" + <BLANKLINE> + <html> + <head></head> + <body> + <b></b> + <p>The above line will be ignored. + </body> + </html> + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + --AAA-- + <BLANKLINE> + +This is true even if the rule does not match. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... MIME-Version: 1.0 + ... Content-Type: multipart/mixed; boundary="AAA" + ... + ... --AAA + ... Content-Type: text/html + ... + ... <html> + ... <head></head> + ... <body> + ... <b>Approve: 123456</b> + ... <p>The above line will be ignored. + ... </body> + ... </html> ... ... --AAA ... Content-Type: text/plain ... - ... Approved: 123456 + ... Approve: 123456 ... An important message. ... --AAA-- ... """) - >>> msgdata = {} - >>> process(mlist, msg, msgdata) + >>> rule.check(mlist, msg, {}) + False + >>> print msg.as_string() From: aperson@example.com MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="AAA" <BLANKLINE> --AAA - Content-Type: application/x-ignore + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/html; charset="us-ascii" <BLANKLINE> - Approved: abcxyz - The above line will be ignored. + <html> + <head></head> + <body> + <b></b> + <p>The above line will be ignored. + </body> + </html> <BLANKLINE> --AAA Content-Transfer-Encoding: 7bit @@ -410,5 +471,3 @@ Here, the correct password is in the non-text/plain part, so it is ignored. An important message. --AAA-- <BLANKLINE> - >>> msgdata - {} diff --git a/Mailman/rules/docs/emergency.txt b/Mailman/rules/docs/emergency.txt new file mode 100644 index 000000000..aecbcb90d --- /dev/null +++ b/Mailman/rules/docs/emergency.txt @@ -0,0 +1,74 @@ +Emergency +========= + +When the mailing list has its emergency flag set, all messages posted to the +list are held for moderator approval. + + >>> from Mailman.app.lifecycle import create_list + >>> mlist = create_list(u'_xtest@example.com') + >>> mlist.web_page_url = u'http://www.example.com/' + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: My first post + ... Message-ID: <first> + ... + ... An important message. + ... """) + +The emergency rule is matched as part of the built-in chain. The emergency +rule matches if the flag is set on the mailing list. + + >>> from Mailman.app.chains import process + >>> mlist.emergency = True + >>> process(mlist, msg, {}, 'built-in') + +There are two messages in the virgin queue. The one addressed to the original +sender will contain a token we can use to grab the held message out of the +pending requests. + + >>> from Mailman.queue import Switchboard + >>> virginq = Switchboard(config.VIRGINQUEUE_DIR) + + >>> def get_held_message(): + ... import re + ... qfiles = [] + ... for filebase in virginq.files: + ... qmsg, qdata = virginq.dequeue(filebase) + ... virginq.finish(filebase) + ... qfiles.append(qmsg) + ... from operator import itemgetter + ... qfiles.sort(key=itemgetter('to')) + ... cookie = None + ... for line in qfiles[1].get_payload().splitlines(): + ... mo = re.search('confirm/[^/]+/(?P<cookie>.*)$', line) + ... if mo: + ... cookie = mo.group('cookie') + ... break + ... assert cookie is not None, 'No confirmation token found' + ... data = config.db.pendings.confirm(cookie) + ... requestdb = config.db.requests.get_list_requests(mlist) + ... rkey, rdata = requestdb.get_request(data['id']) + ... return config.db.message_store.get_message_by_id( + ... rdata['_mod_message_id']) + + >>> msg = get_held_message() + >>> print msg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Mailman-Rule-Hits: emergency + X-Mailman-Rule-Misses: approved + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + <BLANKLINE> + An important message. + <BLANKLINE> + +However, if the message metadata has a 'moderator_approved' key set, then even +if the mailing list has its emergency flag set, the message still goes through +to the membership. + + >>> process(mlist, msg, dict(moderator_approved=True), 'built-in') + >>> len(virginq.files) + 0 diff --git a/Mailman/rules/docs/header-matching.txt b/Mailman/rules/docs/header-matching.txt new file mode 100644 index 000000000..fbd0ff65f --- /dev/null +++ b/Mailman/rules/docs/header-matching.txt @@ -0,0 +1,145 @@ +Header matching +=============== + +Mailman can do pattern based header matching during its normal rule +processing. There is a set of site-wide default header matchines specified in +the configuaration file under the HEADER_MATCHES variable. + + >>> from Mailman.app.lifecycle import create_list + >>> mlist = create_list(u'_xtest@example.com') + +Because the default HEADER_MATCHES variable is empty when the configuration +file is read, we'll just extend the current header matching chain with a +pattern that matches 4 or more stars, discarding the message if it hits. + + >>> from Mailman.configuration import config + >>> chain = config.chains['header-match'] + >>> chain.extend('x-spam-score', '[*]{4,}', 'discard') + +First, if the message has no X-Spam-Score header, the message passes through +the chain untouched (i.e. no disposition). + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: Not spam + ... Message-ID: <one> + ... + ... This is a message. + ... """) + + >>> from Mailman.app.chains import process + +Pass through is seen as nothing being in the log file after processing. + + # XXX This checks the vette log file because there is no other evidence + # that this chain has done anything. + >>> import os + >>> fp = open(os.path.join(config.LOG_DIR, 'vette')) + >>> fp.seek(0, 2) + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: + <BLANKLINE> + +Now, if the header exists but does not match, then it also passes through +untouched. + + >>> msg['X-Spam-Score'] = '***' + >>> del msg['subject'] + >>> msg['Subject'] = 'This is almost spam' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<two>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: + <BLANKLINE> + +But now if the header matches, then the message gets discarded. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '****' + >>> del msg['subject'] + >>> msg['Subject'] = 'This is spam, but barely' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<three>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <three> + <BLANKLINE> + +For kicks, let's show a message that's really spammy. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '**********' + >>> del msg['subject'] + >>> msg['Subject'] = 'This is really spammy' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<four>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <four> + <BLANKLINE> + +Flush out the extended header matching rules. + + >>> chain.flush() + + +List-specific header matching +----------------------------- + +Each mailing list can also be configured with a set of header matching regular +expression rules. These are used to impose list-specific header filtering +with the same semantics as the global `HEADER_MATCHES` variable. + +The list administrator wants to match not on four stars, but on three plus +signs, but only for the current mailing list. + + >>> mlist.header_matches = [('x-spam-score', '[+]{3,}', 'discard')] + +A message with a spam score of two pluses does not match. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '++' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<five>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: + +A message with a spam score of three pluses does match. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '+++' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<six>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <six> + <BLANKLINE> + +As does a message with a spam score of four pluses. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '+++' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<seven>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <seven> + <BLANKLINE> diff --git a/Mailman/rules/docs/implicit-dest.txt b/Mailman/rules/docs/implicit-dest.txt new file mode 100644 index 000000000..5a7f06c0c --- /dev/null +++ b/Mailman/rules/docs/implicit-dest.txt @@ -0,0 +1,76 @@ +Implicit destination +==================== + +The 'implicit-dest' rule matches when the mailing list's posting address is +not explicitly mentioned in the set of message recipients. + + >>> from Mailman.configuration import config + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['implicit-dest'] + >>> rule.name + 'implicit-dest' + +This rule matches messages that have implicit destination, meaning that the +mailing list's posting address isn't included in the explicit recipients. + + >>> mlist.require_explicit_destination = True + >>> mlist.acceptable_aliases = u'' + >>> msg = message_from_string(u"""\ + ... From: aperson@example.org + ... Subject: An implicit message + ... + ... """) + >>> rule.check(mlist, msg, {}) + True + +You can disable implicit destination checks for the mailing list. + + >>> mlist.require_explicit_destination = False + >>> rule.check(mlist, msg, {}) + False + +Even with some recipients, if the posting address is not included, the rule +will match. + + >>> mlist.require_explicit_destination = True + >>> msg['To'] = 'myfriend@example.com' + >>> rule.check(mlist, msg, {}) + True + +Add the posting address as a recipient and the rule will no longer match. + + >>> msg['Cc'] = '_xtest@example.com' + >>> rule.check(mlist, msg, {}) + False + +Alternatively, if one of the acceptable aliases is in the recipients list, +then the rule will not match. + + >>> del msg['cc'] + >>> rule.check(mlist, msg, {}) + True + >>> mlist.acceptable_aliases = u'myfriend@example.com' + >>> rule.check(mlist, msg, {}) + False + +A message gated from NNTP will obviously have an implicit destination. Such +gated messages will not be held for implicit destination because it's assumed +that Mailman pulled it from the appropriate news group. + + >>> rule.check(mlist, msg, dict(fromusenet=True)) + False + + +Alias patterns +-------------- + +It's also possible to specify an alias pattern, i.e. a regular expression to +match against the recipients. For example, we can say that if there is a +recipient in the example.net domain, then the rule does not match. + + >>> mlist.acceptable_aliases = u'^.*@example.net' + >>> rule.check(mlist, msg, {}) + True + >>> msg['To'] = 'you@example.net' + >>> rule.check(mlist, msg, {}) + False diff --git a/Mailman/rules/docs/loop.txt b/Mailman/rules/docs/loop.txt new file mode 100644 index 000000000..8fe86cf45 --- /dev/null +++ b/Mailman/rules/docs/loop.txt @@ -0,0 +1,49 @@ +Posting loops +============= + +To avoid a posting loop, Mailman has a rule to check for the existence of an +X-BeenThere header with the value of the list's posting address. + + >>> from Mailman.configuration import config + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['loop'] + >>> rule.name + 'loop' + +The header could be missing, in which case the rule does not match. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... + ... An important message. + ... """) + >>> rule.check(mlist, msg, {}) + False + +The header could be present, but not match the list's posting address. + + >>> msg['X-BeenThere'] = u'not-this-list@example.com' + >>> rule.check(mlist, msg, {}) + False + +If the header is present and does match the posting address, the rule +matches. + + >>> del msg['x-beenthere'] + >>> msg['X-BeenThere'] = mlist.posting_address + >>> rule.check(mlist, msg, {}) + True + +Even if there are multiple X-BeenThere headers, as long as one with the +posting address exists, the rule matches. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... X-BeenThere: not-this-list@example.com + ... X-BeenThere: _xtest@example.com + ... X-BeenThere: foo@example.com + ... + ... An important message. + ... """) + >>> rule.check(mlist, msg, {}) + True diff --git a/Mailman/rules/docs/max-size.txt b/Mailman/rules/docs/max-size.txt new file mode 100644 index 000000000..0d64b0cf7 --- /dev/null +++ b/Mailman/rules/docs/max-size.txt @@ -0,0 +1,40 @@ +Message size +============ + +The 'message-size' rule matches when the posted message is bigger than a +specified maximum. Generally this is used to prevent huge attachments from +getting posted to the list. This value is calculated in terms of KB (1024 +bytes). + + >>> from Mailman.configuration import config + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['max-size'] + >>> rule.name + 'max-size' + +For example, setting the maximum message size to 1 means that any message +bigger than that will match the rule. + + >>> mlist.max_message_size = 1 # 1024 bytes + >>> one_line = u'x' * 79 + >>> big_body = u'\n'.join([one_line] * 15) + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... + ... """ + big_body) + >>> rule.check(mlist, msg, {}) + True + +Setting the maximum message size to zero means no size check is performed. + + >>> mlist.max_message_size = 0 + >>> rule.check(mlist, msg, {}) + False + +Of course, if the maximum size is larger than the message's size, then it's +still okay. + + >>> mlist.max_message_size = msg.original_size/1024.0 + 1 + >>> rule.check(mlist, msg, {}) + False diff --git a/Mailman/rules/docs/moderation.txt b/Mailman/rules/docs/moderation.txt new file mode 100644 index 000000000..cab8f20d3 --- /dev/null +++ b/Mailman/rules/docs/moderation.txt @@ -0,0 +1,70 @@ +Member moderation +================= + +Each user has a moderation flag. When set, and the list is set to moderate +postings, then only members with a cleared moderation flag will be able to +email the list without having those messages be held for approval. The +'moderation' rule determines whether the message should be moderated or not. + + >>> from Mailman.configuration import config + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['moderation'] + >>> rule.name + 'moderation' + +In the simplest case, the sender is not a member of the mailing list, so the +moderation rule can't match. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.org + ... To: _xtest@example.com + ... Subject: A posted message + ... + ... """) + >>> rule.check(mlist, msg, {}) + False + +Let's add the message author as a non-moderated member. + + >>> user = config.db.user_manager.create_user( + ... u'aperson@example.org', u'Anne Person') + >>> address = list(user.addresses)[0] + >>> from Mailman.interfaces import MemberRole + >>> member = address.subscribe(mlist, MemberRole.member) + >>> member.is_moderated + False + >>> rule.check(mlist, msg, {}) + False + +Once the member's moderation flag is set though, the rule matches. + + >>> member.is_moderated = True + >>> rule.check(mlist, msg, {}) + True + + +Non-members +----------- + +There is another, related rule for matching non-members, which simply matches +if the sender is /not/ a member of the mailing list. + + >>> rule = config.rules['non-member'] + >>> rule.name + 'non-member' + +If the sender is a member of this mailing list, the rule does not match. + + >>> rule.check(mlist, msg, {}) + False + +But if the sender is not a member of this mailing list, the rule matches. + + >>> msg = message_from_string(u"""\ + ... From: bperson@example.org + ... To: _xtest@example.com + ... Subject: A posted message + ... + ... """) + >>> rule.check(mlist, msg, {}) + True diff --git a/Mailman/rules/docs/news-moderation.txt b/Mailman/rules/docs/news-moderation.txt new file mode 100644 index 000000000..f32919ce5 --- /dev/null +++ b/Mailman/rules/docs/news-moderation.txt @@ -0,0 +1,37 @@ +Newsgroup moderation +==================== + +The 'news-moderation' rule matches all messages posted to mailing lists that +gateway to a moderated newsgroup. The reason for this is that such messages +must get forwarded on to the newsgroup moderator. From there it will get +posted to the newsgroup, and from there, gated to the mailing list. It's a +circuitous route, but it works nonetheless by holding all messages posted +directly to the mailing list. + + >>> from Mailman.configuration import config + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['news-moderation'] + >>> rule.name + 'news-moderation' + +Set the list configuraiton variable to enable newsgroup moderation. + + >>> from Mailman.interfaces import NewsModeration + >>> mlist.news_moderation = NewsModeration.moderated + +And now all messages will match the rule. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.org + ... Subject: An announcment + ... + ... Great things are happening. + ... """) + >>> rule.check(mlist, msg, {}) + True + +When moderation is turned off, the rule does not match. + + >>> mlist.news_moderation = NewsModeration.none + >>> rule.check(mlist, msg, {}) + False diff --git a/Mailman/rules/docs/no-subject.txt b/Mailman/rules/docs/no-subject.txt new file mode 100644 index 000000000..3627ac03f --- /dev/null +++ b/Mailman/rules/docs/no-subject.txt @@ -0,0 +1,34 @@ +No Subject header +================= + +This rule matches if the message has no Subject header, or if the header is +the empty string when stripped. + + >>> from Mailman.configuration import config + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['no-subject'] + >>> rule.name + 'no-subject' + +A message with a non-empty subject does not match the rule. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.org + ... To: _xtest@example.com + ... Subject: A posted message + ... + ... """) + >>> rule.check(mlist, msg, {}) + False + +Delete the Subject header and the rule matches. + + >>> del msg['subject'] + >>> rule.check(mlist, msg, {}) + True + +Even a Subject header with only whitespace still matches the rule. + + >>> msg['Subject'] = u' ' + >>> rule.check(mlist, msg, {}) + True diff --git a/Mailman/rules/docs/recipients.txt b/Mailman/rules/docs/recipients.txt new file mode 100644 index 000000000..21d04b8ae --- /dev/null +++ b/Mailman/rules/docs/recipients.txt @@ -0,0 +1,41 @@ +Maximum number of recipients +============================ + +The 'max-recipients' rule matches when there are more than the maximum allowed +number of explicit recipients addressed by the message. + + >>> from Mailman.configuration import config + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['max-recipients'] + >>> rule.name + 'max-recipients' + +In this case, we'll create a message with 5 recipients. These include all +addresses in the To and CC headers. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... To: _xtest@example.com, bperson@example.com + ... Cc: cperson@example.com + ... Cc: dperson@example.com (Dan Person) + ... To: Elly Q. Person <eperson@example.com> + ... + ... Hey folks! + ... """) + +For backward compatibility, the message must have fewer than the maximum +number of explicit recipients. + + >>> mlist.max_num_recipients = 5 + >>> rule.check(mlist, msg, {}) + True + + >>> mlist.max_num_recipients = 6 + >>> rule.check(mlist, msg, {}) + False + +Zero means any number of recipients are allowed. + + >>> mlist.max_num_recipients = 0 + >>> rule.check(mlist, msg, {}) + False diff --git a/Mailman/rules/docs/rules.txt b/Mailman/rules/docs/rules.txt new file mode 100644 index 000000000..d2d291331 --- /dev/null +++ b/Mailman/rules/docs/rules.txt @@ -0,0 +1,70 @@ +Rules +===== + +Rules are applied to each message as part of a rule chain. Individual rules +simply return a boolean specifying whether the rule matches or not. Chain +links determine what happens when a rule matches. + + +All rules +--------- + +Rules are maintained in the configuration object as a dictionary mapping rule +names to rule objects. + + >>> from zope.interface.verify import verifyObject + >>> from Mailman.configuration import config + >>> from Mailman.interfaces import IRule + >>> for rule_name in sorted(config.rules): + ... rule = config.rules[rule_name] + ... print rule_name, verifyObject(IRule, rule) + administrivia True + any True + approved True + emergency True + implicit-dest True + loop True + max-recipients True + max-size True + moderation True + news-moderation True + no-subject True + non-member True + suspicious-header True + truth True + +You can get a rule by name. + + >>> rule = config.rules['emergency'] + >>> verifyObject(IRule, rule) + True + + +Rule checks +----------- + +Individual rules can be checked to see if they match, by running the rule's +`check()` method. This returns a boolean indicating whether the rule was +matched or not. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... + ... An important message. + ... """) + +For example, the emergency rule just checks to see if the emergency flag is +set on the mailing list, and the message has not been pre-approved by the list +administrator. + + >>> rule.name + 'emergency' + >>> mlist.emergency = False + >>> rule.check(mlist, msg, {}) + False + >>> mlist.emergency = True + >>> rule.check(mlist, msg, {}) + True + >>> rule.check(mlist, msg, dict(moderator_approved=True)) + False diff --git a/Mailman/rules/docs/suspicious.txt b/Mailman/rules/docs/suspicious.txt new file mode 100644 index 000000000..6b0eeda35 --- /dev/null +++ b/Mailman/rules/docs/suspicious.txt @@ -0,0 +1,36 @@ +Suspicious headers +================== + +Suspicious headers are a way for Mailman to hold messages that match a +particular regular expression. This mostly historical feature is fairly +confusing to users, and the list attribute that controls this is misnamed. + + >>> from Mailman.configuration import config + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['suspicious-header'] + >>> rule.name + 'suspicious-header' + +Set the so-called suspicious header configuration variable. + + >>> mlist.bounce_matching_headers = u'From: .*person@(blah.)?example.com' + >>> msg = message_from_string(u"""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: An implicit message + ... + ... """) + >>> rule.check(mlist, msg, {}) + True + +But if the header doesn't match the regular expression, the rule won't match. +This one comes from a .org address. + + >>> msg = message_from_string(u"""\ + ... From: aperson@example.org + ... To: _xtest@example.com + ... Subject: An implicit message + ... + ... """) + >>> rule.check(mlist, msg, {}) + False diff --git a/Mailman/rules/docs/truth.txt b/Mailman/rules/docs/truth.txt new file mode 100644 index 000000000..baa40772a --- /dev/null +++ b/Mailman/rules/docs/truth.txt @@ -0,0 +1,10 @@ +Truth +===== + +The 'truth' rule always matches. This makes it useful as a terminus rule for +unconditionally jumping to another chain. + + >>> from Mailman.configuration import config + >>> rule = config.rules['truth'] + >>> rule.check(False, False, False) + True diff --git a/Mailman/rules/emergency.py b/Mailman/rules/emergency.py new file mode 100644 index 000000000..6d924b399 --- /dev/null +++ b/Mailman/rules/emergency.py @@ -0,0 +1,44 @@ +# Copyright (C) 2007-2008 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. + +"""The emergency hold rule.""" + +__all__ = ['Emergency'] +__metaclass__ = type + + +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class Emergency: + """The emergency hold rule.""" + implements(IRule) + + name = 'emergency' + description = _( + """The mailing list is in emergency hold and this message was not + pre-approved by the list administrator. + """) + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return mlist.emergency and not msgdata.get('moderator_approved') diff --git a/Mailman/rules/implicit_dest.py b/Mailman/rules/implicit_dest.py new file mode 100644 index 000000000..1b459caed --- /dev/null +++ b/Mailman/rules/implicit_dest.py @@ -0,0 +1,95 @@ +# Copyright (C) 2007-2008 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. + +"""The implicit destination rule.""" + +__all__ = ['ImplicitDestination'] +__metaclass__ = type + + +import re +from email.utils import getaddresses +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class ImplicitDestination: + """The implicit destination rule.""" + implements(IRule) + + name = 'implicit-dest' + description = _('Catch messages with implicit destination.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # Implicit destination checking must be enabled in the mailing list. + if not mlist.require_explicit_destination: + return False + # Messages gated from NNTP will always have an implicit destination so + # are never checked. + if msgdata.get('fromusenet'): + return False + # Calculate the list of acceptable aliases. If the alias starts with + # a caret (i.e. ^), then it's a regular expression to match against. + aliases = set() + alias_patterns = set() + for alias in mlist.acceptable_aliases.splitlines(): + alias = alias.strip().lower() + if alias.startswith('^'): + alias_patterns.add(alias) + elif '@' in alias: + aliases.add(alias) + else: + # This is not a regular expression, nor a fully-qualified + # email address, so skip it. + pass + # Add the list's posting address, i.e. the explicit address, to the + # set of acceptable aliases. + aliases.add(mlist.posting_address) + # Look at all the recipients. If the recipient is any acceptable + # alias (or the explicit posting address), then this rule does not + # match. If not, then add it to the set of recipients we'll check + # against the alias patterns later. + recipients = set() + for header in ('to', 'cc', 'resent-to', 'resent-cc'): + for fullname, address in getaddresses(msg.get_all(header, [])): + address = address.lower() + if address in aliases: + return False + recipients.add(address) + # Now for all alias patterns, see if any of the recipients matches a + # pattern. If so, then this rule does not match. + for pattern in alias_patterns: + escaped = re.escape(pattern) + for recipient in recipients: + try: + if re.match(pattern, recipient, re.IGNORECASE): + return False + except re.error: + # The pattern is a malformed regular expression. Try + # matching again with the pattern escaped. + try: + if re.match(escaped, recipient, re.IGNORECASE): + return False + except re.error: + pass + # Nothing matched. + return True diff --git a/Mailman/rules/loop.py b/Mailman/rules/loop.py new file mode 100644 index 000000000..93bd1241d --- /dev/null +++ b/Mailman/rules/loop.py @@ -0,0 +1,44 @@ +# Copyright (C) 2007-2008 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. + +"""Look for a posting loop.""" + +__all__ = ['Loop'] +__metaclass__ = type + + +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class Loop: + """Look for a posting loop.""" + implements(IRule) + + name = 'loop' + description = _("""Look for a posting loop, via the X-BeenThere header.""") + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # Has this message already been posted to this list? + been_theres = [value.strip().lower() + for value in msg.get_all('x-beenthere', [])] + return mlist.posting_address in been_theres diff --git a/Mailman/rules/max_recipients.py b/Mailman/rules/max_recipients.py new file mode 100644 index 000000000..e4fb3faf3 --- /dev/null +++ b/Mailman/rules/max_recipients.py @@ -0,0 +1,48 @@ +# Copyright (C) 2007-2008 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. + +"""The maximum number of recipients rule.""" + +__all__ = ['MaximumRecipients'] +__metaclass__ = type + + +from email.utils import getaddresses +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class MaximumRecipients: + """The maximum number of recipients rule.""" + implements(IRule) + + name = 'max-recipients' + description = _('Catch messages with too many explicit recipients.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # Zero means any number of recipients are allowed. + if mlist.max_num_recipients == 0: + return False + # Figure out how many recipients there are + recipients = getaddresses(msg.get_all('to', []) + + msg.get_all('cc', [])) + return len(recipients) >= mlist.max_num_recipients diff --git a/Mailman/rules/max_size.py b/Mailman/rules/max_size.py new file mode 100644 index 000000000..e54b68c9c --- /dev/null +++ b/Mailman/rules/max_size.py @@ -0,0 +1,46 @@ +# Copyright (C) 2007-2008 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. + +"""The maximum message size rule.""" + +__all__ = ['MaximumSize'] +__metaclass__ = type + + +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class MaximumSize: + """The implicit destination rule.""" + implements(IRule) + + name = 'max-size' + description = _('Catch messages that are bigger than a specified maximum.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + if mlist.max_message_size == 0: + return False + assert hasattr(msg, 'original_size'), ( + 'Message was not sized on initial parsing.') + # The maximum size is specified in 1024 bytes. + return msg.original_size / 1024.0 > mlist.max_message_size diff --git a/Mailman/rules/moderation.py b/Mailman/rules/moderation.py new file mode 100644 index 000000000..29a478662 --- /dev/null +++ b/Mailman/rules/moderation.py @@ -0,0 +1,66 @@ +# Copyright (C) 2007-2008 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. + +"""Membership related rules.""" + +__all__ = [ + 'Moderation', + 'NonMember', + ] +__metaclass__ = type + + +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class Moderation: + """The member moderation rule.""" + implements(IRule) + + name = 'moderation' + description = _('Match messages sent by moderated members.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + for sender in msg.get_senders(): + member = mlist.members.get_member(sender) + if member is not None and member.is_moderated: + return True + return False + + + +class NonMember: + """The non-membership rule.""" + implements(IRule) + + name = 'non-member' + description = _('Match messages sent by non-members.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + for sender in msg.get_senders(): + if mlist.members.get_member(sender) is not None: + # The sender is a member of the mailing list. + return False + return True diff --git a/Mailman/rules/news_moderation.py b/Mailman/rules/news_moderation.py new file mode 100644 index 000000000..56c9fef37 --- /dev/null +++ b/Mailman/rules/news_moderation.py @@ -0,0 +1,44 @@ +# Copyright (C) 2007-2008 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. + +"""The news moderation rule.""" + +__all__ = ['ModeratedNewsgroup'] +__metaclass__ = type + + +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule, NewsModeration + + + +class ModeratedNewsgroup: + """The news moderation rule.""" + implements(IRule) + + name = 'news-moderation' + description = _( + u"""Match all messages posted to a mailing list that gateways to a + moderated newsgroup. + """) + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return mlist.news_moderation == NewsModeration.moderated diff --git a/Mailman/rules/no_subject.py b/Mailman/rules/no_subject.py new file mode 100644 index 000000000..4a8c6ac99 --- /dev/null +++ b/Mailman/rules/no_subject.py @@ -0,0 +1,42 @@ +# Copyright (C) 2007-2008 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. + +"""The no-Subject header rule.""" + +__all__ = ['NoSubject'] +__metaclass__ = type + + +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class NoSubject: + """The no-Subject rule.""" + implements(IRule) + + name = 'no-subject' + description = _('Catch messages with no, or empty, Subject headers.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + subject = msg.get('subject', '').strip() + return subject == '' diff --git a/Mailman/rules/suspicious.py b/Mailman/rules/suspicious.py new file mode 100644 index 000000000..4936734e5 --- /dev/null +++ b/Mailman/rules/suspicious.py @@ -0,0 +1,93 @@ +# Copyright (C) 2007-2008 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. + +"""The historical 'suspicious header' rule.""" + +__all__ = ['SuspiciousHeader'] +__metaclass__ = type + + +import re +from email.utils import getaddresses +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class SuspiciousHeader: + """The historical 'suspicious header' rule.""" + implements(IRule) + + name = 'suspicious-header' + description = _('Catch messages with suspicious headers.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return (mlist.bounce_matching_headers and + has_matching_bounce_header(mlist, msg)) + + + +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/rules/truth.py b/Mailman/rules/truth.py new file mode 100644 index 000000000..d3cfc30f3 --- /dev/null +++ b/Mailman/rules/truth.py @@ -0,0 +1,41 @@ +# Copyright (C) 2008 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. + +"""A rule which always matches.""" + +__all__ = ['Truth'] +__metaclass__ = type + + +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class Truth: + """Look for any previous rule match.""" + implements(IRule) + + name = 'truth' + description = _('A rule which always matches.') + record = False + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return True diff --git a/Mailman/templates/en/__init__.py b/Mailman/templates/en/__init__.py index e69de29bb..e69de29bb 100755..100644 --- a/Mailman/templates/en/__init__.py +++ b/Mailman/templates/en/__init__.py diff --git a/Mailman/templates/en/postauth.txt b/Mailman/templates/en/postauth.txt index a10727716..19f1e384a 100644 --- a/Mailman/templates/en/postauth.txt +++ b/Mailman/templates/en/postauth.txt @@ -1,7 +1,7 @@ As list administrator, your authorization is requested for the following mailing list posting: - List: %(listname)s@%(hostname)s + List: %(listname)s From: %(sender)s Subject: %(subject)s Reason: %(reason)s diff --git a/Mailman/tests/bounces/__init__.py b/Mailman/tests/bounces/__init__.py index e69de29bb..e69de29bb 100755..100644 --- a/Mailman/tests/bounces/__init__.py +++ b/Mailman/tests/bounces/__init__.py diff --git a/Mailman/tests/helpers.py b/Mailman/tests/helpers.py new file mode 100644 index 000000000..1b24f11e6 --- /dev/null +++ b/Mailman/tests/helpers.py @@ -0,0 +1,64 @@ +# Copyright (C) 2008 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. + +"""Various test helpers.""" + +__metaclass__ = type +__all__ = [ + 'get_queue_messages', + 'make_testable_runner', + ] + + + +def make_testable_runner(runner_class): + """Create a queue runner that runs until its queue is empty. + + :param runner_class: An IRunner + :return: A runner instance. + """ + + class EmptyingRunner(runner_class): + """Stop processing when the queue is empty.""" + + def _doperiodic(self): + """Stop when the queue is empty.""" + self._stop = (len(self._switchboard.files) == 0) + + return EmptyingRunner() + + + +class _Bag: + def __init__(self, **kws): + for key, value in kws.items(): + setattr(self, key, value) + + +def get_queue_messages(queue): + """Return and clear all the messages in the given queue. + + :param queue: An ISwitchboard + :return: A list of 2-tuples where each item contains the message and + message metadata. + """ + messages = [] + for filebase in queue.files: + msg, msgdata = queue.dequeue(filebase) + messages.append(_Bag(msg=msg, msgdata=msgdata)) + queue.finish(filebase) + return messages diff --git a/Mailman/tests/smtplistener.py b/Mailman/tests/smtplistener.py index 565772b1d..977726247 100644 --- a/Mailman/tests/smtplistener.py +++ b/Mailman/tests/smtplistener.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2008 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 @@ -15,8 +15,11 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. +"""A test SMTP listener.""" + import sys import smtpd +import signal import mailbox import asyncore import optparse @@ -29,34 +32,68 @@ DEFAULT_PORT = 9025 class Channel(smtpd.SMTPChannel): - def smtp_EXIT(self, arg): - raise asyncore.ExitNow + """A channel that can reset the mailbox.""" + + def __init__(self, server, conn, addr): + smtpd.SMTPChannel.__init__(self, server, conn, addr) + # Stash this here since the subclass uses private attributes. :( + self._server = server + + def smtp_RSET(self, arg): + """Respond to RSET and clear the mailbox.""" + self._server.clear_mailbox() + smtpd.SMTPChannel.smtp_RSET(self, arg) + def send(self, data): + """Silence the bloody asynchat/asyncore broken pipe errors!""" + try: + return smtpd.SMTPChannel.send(self, data) + except socket.error: + # Nothing here can affect the outcome, and these messages are just + # plain annoying! So ignore them. + pass + + class Server(smtpd.SMTPServer): - def __init__(self, localaddr, mboxfile): + """An SMTP server that stores messages to a mailbox.""" + + def __init__(self, localaddr, mailbox_path): smtpd.SMTPServer.__init__(self, localaddr, None) - self._mbox = mailbox.mbox(mboxfile) + self._mailbox = mailbox.Maildir(mailbox_path) def handle_accept(self): + """Handle connections by creating our own Channel object.""" conn, addr = self.accept() Channel(self, conn, addr) def process_message(self, peer, mailfrom, rcpttos, data): + """Process a message by adding it to the mailbox.""" msg = message_from_string(data) msg['X-Peer'] = peer msg['X-MailFrom'] = mailfrom msg['X-RcptTo'] = COMMASPACE.join(rcpttos) - self._mbox.add(msg) + self._mailbox.add(msg) + self._mailbox.clean() - def close(self): - self._mbox.flush() - self._mbox.close() + + +def handle_signal(*ignore): + """Handle signal sent by parent to kill the process.""" + asyncore.socket_map.clear() def main(): - parser = optparse.OptionParser(usage='%prog mboxfile') + parser = optparse.OptionParser(usage="""\ +%prog [options] mboxfile + +This starts a process listening on a specified host and port (by default +localhost:9025) for SMTP conversations. All messages this process receives +are stored in a specified mbox file for the parent process to investigate. + +This SMTP server responds to RSET commands by clearing the mbox file. +""") parser.add_option('-a', '--address', type='string', default=None, help='host:port to listen on') @@ -77,12 +114,14 @@ def main(): host, port = opts.address.split(':', 1) port = int(port) + # Catch the parent's exit signal, and also C-c. + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + server = Server((host, port), mboxfile) - try: - asyncore.loop() - except asyncore.ExitNow: - asyncore.close_all() - server.close() + asyncore.loop() + asyncore.close_all() + server.close() return 0 diff --git a/Mailman/tests/test_documentation.py b/Mailman/tests/test_documentation.py index 390ba6a66..ad00ba19c 100644 --- a/Mailman/tests/test_documentation.py +++ b/Mailman/tests/test_documentation.py @@ -18,7 +18,6 @@ """Harness for testing Mailman's documentation.""" import os -import pdb import doctest import unittest @@ -31,12 +30,25 @@ from Mailman.app.styles import style_manager from Mailman.configuration import config +DOT = '.' COMMASPACE = ', ' def specialized_message_from_string(text): - return message_from_string(text, Message) + """Parse text into a message object. + + This is specialized in the sense that an instance of Mailman's own Message + object is returned, and this message object has an attribute + `original_size` which is the pre-calculated size in bytes of the message's + text representation. + """ + # This mimic what Switchboard.dequeue() does when parsing a message from + # text into a Message instance. + original_size = len(text) + message = message_from_string(text, Message) + message.original_size = original_size + return message def setup(testobj): @@ -66,7 +78,12 @@ def cleaning_teardown(testobj): def test_suite(): suite = unittest.TestSuite() - docsdir = os.path.join(os.path.dirname(Mailman.__file__), 'docs') + topdir = os.path.dirname(Mailman.__file__) + packages = [] + for dirpath, dirnames, filenames in os.walk(topdir): + if 'docs' in dirnames: + docsdir = os.path.join(dirpath, 'docs')[len(topdir)+1:] + packages.append(docsdir) # Under higher verbosity settings, report all doctest errors, not just the # first one. flags = (doctest.ELLIPSIS | @@ -74,13 +91,15 @@ def test_suite(): doctest.REPORT_NDIFF) if config.opts.verbosity <= 2: flags |= doctest.REPORT_ONLY_FIRST_FAILURE - for filename in os.listdir(docsdir): - if os.path.splitext(filename)[1] == '.txt': - test = doctest.DocFileSuite( - 'docs/' + filename, - package=Mailman, - optionflags=flags, - setUp=setup, - tearDown=cleaning_teardown) - suite.addTest(test) + # Add all the doctests in all subpackages. + for docsdir in packages: + for filename in os.listdir(os.path.join('Mailman', docsdir)): + if os.path.splitext(filename)[1] == '.txt': + test = doctest.DocFileSuite( + os.path.join(docsdir, filename), + package='Mailman', + optionflags=flags, + setUp=setup, + tearDown=cleaning_teardown) + suite.addTest(test) return suite diff --git a/docs/NEWS.txt b/docs/NEWS.txt index 2cc28963e..89dc9c06d 100644 --- a/docs/NEWS.txt +++ b/docs/NEWS.txt @@ -1,10 +1,11 @@ Mailman - The GNU Mailing List Management System -Copyright (C) 1998-2007 by the Free Software Foundation, Inc. +Copyright (C) 1998-2008 by the Free Software Foundation, Inc. 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA Here is a history of user visible changes to Mailman. -3.0 alpha 1 (XX-XXX-200X) +3.0 alpha 1 -- "Leave That Thing Alone" +(XX-XXX-200X) User visible changes @@ -40,10 +41,22 @@ Here is a history of user visible changes to Mailman. instead. The substitution variable $fqdn_listname has been added. DEFAULT_MSG_FOOTER in Defaults.py.in hsa been updated accordingly. + - The KNOWN_SPAMMERS global variable is replaced with HEADER_MATCHES. The + mailing list's header_filter_rules variable is replaced with + header_matches which has the same semantics as HEADER_MATCHES, but is + list-specific. + + - DEFAULT_MAIL_COMMANDS_MAX_LINES -> EMAIL_COMMANDS_MAX_LINES + Architecture - - SQLAlchemy/Elixir based storage for all list and user data, with default - storage in a SQLite database. + - Implementation of a chain-of-rules based approach for deciding whether a + message should initially be accepted, held for approval, + rejected/bounced, or discarded. This replaces most of the disposition + handlers in the pipeline. + + - The Storm ORM is used for data storage, with the SQLite backend as the + default relational database. - Zope interfaces are used to describe the major components. @@ -126,6 +139,9 @@ Here is a history of user visible changes to Mailman. been removed. The domain that the list is being added to must already exist. + - Backport the ability to specify additional footer interpolation + variables by the message metadata 'decoration-data' key. + Bug fixes and other patches - Removal of DomainKey/DKIM signatures is now controlled by Defaults.py @@ -148,6 +164,9 @@ Here is a history of user visible changes to Mailman. incorrectly suppressed in messages that Mailman sends directly to users. + - The 'adminapproved' metadata key is renamed 'moderator_approved'. + + 2.1.9 (12-Sep-2006) Security @@ -88,6 +88,8 @@ Any other spelling is incorrect.""", 'mailman.database' : 'stock = Mailman.database:StockDatabase', 'mailman.mta' : 'stock = Mailman.MTA:Manual', 'mailman.styles' : 'default = Mailman.app.styles:DefaultStyle', + 'mailman.mta' : 'stock = Mailman.MTA:Manual', + 'mailman.rules' : 'default = Mailman.rules:initialize', }, # Third-party requirements. install_requires = [ |
